Compare commits

..

32 Commits

Author SHA1 Message Date
Jose Selesan
04b3f87aca feat(wallets): implement wallet movements feature with filtering and pagination 2026-06-17 11:10:20 -03:00
Jose Selesan
33120deed6 feat(expenses): add walletId to expense model and implement wallet adjustments in expense operations 2026-06-17 10:54:43 -03:00
Jose Selesan
5671b4a14b feat(wallets): add isDefault field to wallet model and implement setDefaultWallet functionality 2026-06-17 10:15:44 -03:00
798d657869 Merge pull request 'feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements' (#4) from feat/wallets into main
Reviewed-on: #4
2026-06-17 12:37:20 +00:00
Jose Selesan
1d954217b7 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.
2026-06-17 09:36:55 -03:00
Jose Selesan
1ff2abc16a feat(header): add Telegram icon and link to settings based on connection status 2026-06-17 09:01:28 -03:00
Jose Selesan
b8d8f16f4e feat(telegram): integrate Telegram bot for notifications and add related models 2026-06-17 08:42:13 -03:00
Jose Selesan
c0c8bf0945 feat(analysis): make analysis page work for all currencies (BELO, BLUE, BNA)
- Replace hardcoded BELO analysis with generic AnalysisPage
- Add /quotes/analysis/ route with currency path param
- Add currency selector tabs on analysis page
- Dashboard QuoteCards now navigate to analysis for all currencies
- Old /quotes/belo/analysis redirects to /quotes/analysis/BELO
2026-06-12 16:44:20 -03:00
Jose Selesan
142fd4e33f feat(expenses): add beloPrice and usdcEquivalent fields to Expense model and update related logic 2026-06-08 21:13:03 -03:00
Jose Selesan
b08399908b feat(expenses): allow editing paid expenses (status, amountPayed, paymentDate) 2026-06-08 08:12:46 -03:00
Jose Selesan
6f06ee18da feat(belo): add current quote card and improve date formats on analysis page 2026-06-04 12:09:03 -03:00
Jose Selesan
b4f4030ee8 feat(layout): make header sticky so it's always visible on scroll 2026-06-04 11:50:05 -03:00
Jose Selesan
824f3a18ab fix(belo): improve mobile header layout - stack title above actions on small screens 2026-06-04 11:38:10 -03:00
Jose Selesan
0915ad1223 feat: enhance date handling and formatting in quotes and belo features 2026-06-04 11:32:38 -03:00
Jose Selesan
7c864053bb feat(belo): add daily and monthly variation quotes to analysis page 2026-06-04 11:10:37 -03:00
Jose Selesan
9ce5511ad8 feat(dashboard): add Dolar Oficial (BNA) card to panel grid 2026-06-04 11:05:38 -03:00
Jose Selesan
55098a7b95 feat(expenses): add edit amount and due date for pending expenses 2026-06-04 11:00:36 -03:00
Jose Selesan
5b6ccf0fa6 chore: add Biome for lint and fix all lint errors
- Install @biomejs/biome as devDependency at root
- Configure biome.json with 2-space indent, double quotes, Tailwind CSS support
- Add lint/lint:fix/format/format:fix scripts to root and app package.json
- Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button>
- Fix noUnusedVariables: remove unused destructured vars
- Fix useIterableCallbackReturn: arrow functions with block body
- Fix noExplicitAny: recharts Tooltip formatters
- Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels
- Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays
- Fix noArrayIndexKey: use error string as key
- Fix CSS parse: enable tailwindDirectives parser
- Normalize formatting across 80 files with biome check --write
2026-06-04 10:48:07 -03:00
Jose Selesan
53a797703e feat(dashboard): add pending/upcoming expenses table
- Backend: GET /expenses/pending-upcoming returns PENDING expenses with dueDate <= today+5 days, classified as overdue/upcoming
- DashboardExpensesTable: groups by periodicExpenseId, shows summed totals with status badges, links to /expenses?periodicExpenseId=X
- Expenses route accepts ?periodicExpenseId search param and pre-selects it in the filter
- fix: BeloAnalysisPage DatePicker type mismatch
2026-06-04 10:34:34 -03:00
33248401f7 Merge pull request 'feat(belo): add analysis page with historical stats, chart, and date range picker' (#3) from feat/belo-analysis into main
Reviewed-on: #3
2026-06-04 12:49:01 +00:00
Jose Selesan
8b8a4670cb feat(belo): add analysis page with historical stats, chart, and date range picker 2026-06-04 09:48:20 -03:00
Jose Selesan
bd53b26f29 Hide scrollbars on expense tab strips 2026-06-01 08:20:19 -03:00
Jose Selesan
2fd23cedc6 Improve expenses mobile layout and payment flow 2026-06-01 08:03:48 -03:00
Jose Selesan
988d58d761 feat(cache): implement a simple caching mechanism with TTL and prefix invalidation
refactor(expenses): integrate caching for periodic expenses and related operations
refactor(quotes): add caching for quotes retrieval and history endpoints
2026-05-29 19:25:15 -03:00
Jose Selesan
801f04df12 feat(dashboard): add monthly totals data to DashboardPage and update pending payments display 2026-05-29 19:13:22 -03:00
Jose Selesan
f6c03e3c91 refactor(expenses): optimize monthly totals and pending calculations using raw SQL queries 2026-05-29 19:05:23 -03:00
Jose Selesan
3daf67639a refactor(expenses): optimize expense aggregation queries for improved performance 2026-05-29 19:00:03 -03:00
Jose Selesan
94aab6dce7 feat(logging): add request logger middleware for improved request tracking 2026-05-29 18:53:34 -03:00
Jose Selesan
98b6d7e4d3 fix(prisma): update seed script in package.json and prisma.config.ts for consistency 2026-05-29 18:48:30 -03:00
Jose Selesan
629203dac3 feat(database): add database seeding to entrypoint script and package.json 2026-05-29 18:45:38 -03:00
Jose Selesan
7702eb054c feat(dependencies): add better-auth package to frontend and backend 2026-05-29 18:06:00 -03:00
067710f1ae Merge pull request 'feat/add-better-auth' (#2) from feat/add-better-auth into main
Reviewed-on: #2
2026-05-29 19:08:20 +00:00
128 changed files with 7417 additions and 865 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ node_modules
dist dist
build build
.vite .vite
.tanstack

View File

@@ -19,4 +19,7 @@ if [ $i -ge $max_retries ]; then
exit 1 exit 1
fi fi
echo "Running database seed..."
bun run --cwd apps/backend prisma:seed && echo "Seed complete" || echo "Seed skipped or already applied"
exec "$@" exec "$@"

View File

@@ -8,13 +8,18 @@
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:migrate:deploy": "prisma migrate deploy" "prisma:migrate:deploy": "prisma migrate deploy",
"prisma:seed": "bun run prisma/seed.ts",
"lint": "biome check src/",
"lint:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.1.1", "@noble/ciphers": "^2.1.1",
"@personal-admin/common": "workspace:*", "@personal-admin/common": "workspace:*",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"better-auth": "^1.6.11",
"grammy": "^1.44.0",
"hono": "^4.7.0", "hono": "^4.7.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg": "^8.21.0", "pg": "^8.21.0",

View File

@@ -7,8 +7,9 @@ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
seed: "bun ./prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env.DATABASE_URL,
}, },
}); });

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY,
ADD COLUMN "usdcEquivalent" DECIMAL(12,6);

View File

@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "telegram_chats" (
"id" TEXT NOT NULL,
"chatId" BIGINT,
"code" TEXT NOT NULL,
"validatedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "telegram_chats_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "telegram_daily_max_notifications" (
"id" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"quoteType" "QuoteType" NOT NULL,
"maxValue" MONEY NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "telegram_daily_max_notifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "telegram_chats_chatId_key" ON "telegram_chats"("chatId");
-- CreateIndex
CREATE UNIQUE INDEX "telegram_chats_code_key" ON "telegram_chats"("code");
-- CreateIndex
CREATE UNIQUE INDEX "telegram_daily_max_notifications_date_quoteType_key" ON "telegram_daily_max_notifications"("date", "quoteType");

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "expenses" ADD COLUMN "walletId" INTEGER;
-- CreateIndex
CREATE INDEX "expenses_walletId_idx" ON "expenses"("walletId");
-- AddForeignKey
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -69,6 +69,28 @@ model Verification {
@@map("verification") @@map("verification")
} }
model TelegramChat {
id String @id @default(cuid())
chatId BigInt? @unique
code String @unique
validatedAt DateTime?
createdAt DateTime @default(now())
@@map("telegram_chats")
}
model TelegramDailyMaxNotification {
id String @id @default(cuid())
date DateTime
quoteType QuoteType
maxValue Decimal @db.Money
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([date, quoteType])
@@map("telegram_daily_max_notifications")
}
enum QuoteType { enum QuoteType {
BLUE BLUE
BNA BNA
@@ -107,6 +129,37 @@ 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)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements WalletMovement[]
expenses Expense[]
@@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)
@@ -130,9 +183,14 @@ model Expense {
month Int month Int
amount Decimal @db.Money amount Decimal @db.Money
amountPayed Decimal? @db.Money amountPayed Decimal? @db.Money
beloPrice Decimal? @db.Money
usdcEquivalent Decimal? @db.Decimal(12, 6)
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
dueDate DateTime dueDate DateTime
paymentDate DateTime? paymentDate DateTime?
walletId Int?
wallet Wallet? @relation(fields: [walletId], references: [id])
@@index([walletId])
@@map("expenses") @@map("expenses")
} }

View File

@@ -1,10 +1,13 @@
import "dotenv/config"; import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "../src/generated/prisma/client"; import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); const adapter = new PrismaPg({
// biome-ignore lint/style/noNonNullAssertion: required env var
connectionString: process.env.DATABASE_URL!,
});
const prisma = new PrismaClient({ adapter }); const prisma = new PrismaClient({ adapter });
async function main() { async function main() {
@@ -26,6 +29,7 @@ async function main() {
disableSignUp: false, disableSignUp: false,
minPasswordLength: 4, minPasswordLength: 4,
}, },
// biome-ignore lint/style/noNonNullAssertion: required env var
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000", baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
}); });

View File

@@ -2,14 +2,22 @@ import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { auth } from "./lib/auth"; import { auth } from "./lib/auth";
import { authMiddleware } from "./lib/auth-middleware"; import { authMiddleware } from "./lib/auth-middleware";
import { cache } from "./lib/cache";
import { eventBus } from "./lib/event-bus";
import { logger } from "./lib/logger";
import { requestLogger } from "./lib/request-logger";
import { startBot } from "./lib/telegram";
import { startExpenseJob } from "./modules/expenses/expense.job";
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 expensesRouter from "./modules/expenses/expenses.routes"; import telegramRouter from "./modules/telegram/telegram.router";
import { startExpenseJob } from "./modules/expenses/expense.job"; import walletsRouter from "./modules/wallets/wallets.routes";
import { logger } from "./lib/logger";
const app = new Hono(); const app = new Hono();
app.use("*", requestLogger);
app.get("/api/health", (c) => { app.get("/api/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() }); return c.json({ status: "ok", timestamp: new Date().toISOString() });
}); });
@@ -25,12 +33,19 @@ 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/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" }));
eventBus.on("quotes-updated", () => {
cache.invalidateByPrefix("quotes:");
});
startQuoteJob(); startQuoteJob();
startExpenseJob(); startExpenseJob();
startBot();
logger.info("Backend started"); logger.info("Backend started");

View File

@@ -2,6 +2,11 @@ import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
// biome-ignore lint/style/noNonNullAssertion: required env vars
const secret = process.env.BETTER_AUTH_SECRET!;
// biome-ignore lint/style/noNonNullAssertion: required env vars
const baseURL = process.env.BETTER_AUTH_URL!;
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: "postgresql", provider: "postgresql",
@@ -11,10 +16,7 @@ export const auth = betterAuth({
disableSignUp: true, disableSignUp: true,
minPasswordLength: 4, minPasswordLength: 4,
}, },
secret: process.env.BETTER_AUTH_SECRET!, secret,
baseURL: process.env.BETTER_AUTH_URL!, baseURL,
trustedOrigins: [ trustedOrigins: [baseURL, "http://localhost:5173"],
process.env.BETTER_AUTH_URL!,
"http://localhost:5173",
],
}); });

View File

@@ -0,0 +1,42 @@
const TEN_MINUTES = 10 * 60 * 1000;
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class SimpleCache {
private store = new Map<string, CacheEntry<unknown>>();
get<T>(key: string): T | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs: number = TEN_MINUTES): void {
this.store.set(key, { data, expiresAt: Date.now() + ttlMs });
}
delete(key: string): void {
this.store.delete(key);
}
invalidateByPrefix(prefix: string): void {
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
clear(): void {
this.store.clear();
}
}
export const cache = new SimpleCache();

View File

@@ -7,12 +7,14 @@ class EventBus {
if (!this.listeners.has(event)) { if (!this.listeners.has(event)) {
this.listeners.set(event, new Set()); this.listeners.set(event, new Set());
} }
this.listeners.get(event)!.add(listener); this.listeners.get(event)?.add(listener);
return () => this.listeners.get(event)?.delete(listener); return () => this.listeners.get(event)?.delete(listener);
} }
emit(event: string, ...args: unknown[]): void { emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((listener) => listener(...args)); this.listeners.get(event)?.forEach((listener) => {
listener(...args);
});
} }
} }

View File

@@ -2,5 +2,7 @@ import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client"; import { PrismaClient } from "../generated/prisma/client";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); // biome-ignore lint/style/noNonNullAssertion: required env var
const databaseUrl = process.env.DATABASE_URL!;
const adapter = new PrismaPg({ connectionString: databaseUrl });
export const prisma = new PrismaClient({ adapter }); export const prisma = new PrismaClient({ adapter });

View File

@@ -0,0 +1,15 @@
import type { Context, Next } from "hono";
import { logger } from "./logger";
export async function requestLogger(c: Context, next: Next) {
const start = performance.now();
const method = c.req.method;
const path = c.req.path;
logger.info(`${method} ${path}`);
await next();
const duration = (performance.now() - start).toFixed(2);
logger.info(`${method} ${path} ${duration}ms`);
}

View File

@@ -0,0 +1,55 @@
import { Bot } from "grammy";
import { logger } from "./logger";
import { validateCode } from "../modules/telegram/telegram.service";
const token = process.env.BOT_TOKEN;
let botInstance: Bot | null = null;
let botUsername: string | null = null;
export function getBot(): Bot | null {
return botInstance;
}
export function getBotUsername(): string | null {
return botUsername;
}
export async function startBot(): Promise<void> {
if (!token) {
logger.warn("BOT_TOKEN not set, Telegram bot not started");
return;
}
try {
const bot = new Bot(token);
bot.command("start", async (ctx) => {
await ctx.reply(
"🔗 Enviá el código de verificación que aparece en la Configuración de la app para conectar tu cuenta de Telegram.\n\nUna vez conectado, recibirás notificaciones cuando BELO alcance el máximo del día.",
);
});
bot.on("message:text", async (ctx) => {
const text = ctx.message.text.trim();
if (text.startsWith("/")) return;
const success = await validateCode(BigInt(ctx.chat.id), text);
if (success) {
await ctx.reply("✅ ¡Conectado! Recibirás notificaciones de BELO aquí.");
} else {
await ctx.reply("❌ Código inválido o ya utilizado.");
}
});
const botInfo = await bot.api.getMe();
botUsername = botInfo.username;
bot.start();
botInstance = bot;
logger.info({ botUsername }, "Telegram bot started");
} catch (error) {
logger.error(error, "Failed to start Telegram bot");
}
}

View File

@@ -8,3 +8,13 @@ export function startOfToday(): Date {
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
return d; return d;
} }
export function parseLocalDate(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, m - 1, d);
}
export function startOfNextLocalDay(dateStr: string): Date {
const [y, m, d] = dateStr.split("-").map(Number);
return new Date(y, m - 1, d + 1);
}

View File

@@ -1,17 +1,19 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
import { listExpensesHandler } from "./handlers/listExpenses";
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense"; import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
import { payExpenseHandler } from "./handlers/payExpense"; import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal"; import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals"; import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
import { getTotalPendingHandler } from "./handlers/getTotalPending"; import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
import { importExpensesHandler } from "./handlers/importExpenses"; import { importExpensesHandler } from "./handlers/importExpenses";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
import { listExpensesHandler } from "./handlers/listExpenses";
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
import { payExpenseHandler } from "./handlers/payExpense";
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
import { updateExpenseHandler } from "./handlers/updateExpense";
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
const app = new Hono(); const app = new Hono();
@@ -20,14 +22,19 @@ app.post("/periodic-expenses", createPeriodicExpenseHandler);
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler); app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler); app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
app.post("/periodic-expenses/import", importPeriodicExpensesHandler); app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler); app.post(
"/periodic-expenses/:id/generate-month",
generateMonthlyExpenseHandler,
);
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler); app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
app.get("/expenses/totals", getMonthlyTotalsHandler); app.get("/expenses/totals", getMonthlyTotalsHandler);
app.get("/expenses/pending-upcoming", getPendingUpcomingExpensesHandler);
app.get("/expenses/pending-total", getTotalPendingHandler); app.get("/expenses/pending-total", getTotalPendingHandler);
app.get("/expenses", listExpensesHandler); app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler); app.post("/expenses", createNonPeriodicExpenseHandler);
app.post("/expenses/import", importExpensesHandler); app.post("/expenses/import", importExpensesHandler);
app.put("/expenses/:id/pay", payExpenseHandler); app.put("/expenses/:id/pay", payExpenseHandler);
app.put("/expenses/:id", updateExpenseHandler);
export default app; export default app;

View File

@@ -1,6 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma"; import { prisma } from "../../lib/prisma";
import { PaymentStatus } from "../../generated/prisma/client"; import { roundTo } from "../../lib/utils";
export const createPeriodicExpenseSchema = z.object({ export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), description: z.string().min(1).max(50),
@@ -15,18 +17,42 @@ export const createNonPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), description: z.string().min(1).max(50),
amount: z.number().positive(), amount: z.number().positive(),
dueDate: z.string().datetime(), dueDate: z.string().datetime(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
}); });
export const payExpenseSchema = z.object({ export const payExpenseSchema = z.object({
amountPayed: z.number().positive(), amountPayed: z.number().positive(),
paymentDate: z.string().datetime().optional(), paymentDate: z.string().datetime().optional(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
});
export const updateExpenseSchema = z.object({
amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z
.string()
.datetime({ message: "La fecha debe ser una fecha ISO válida" }),
status: z.enum(["PENDING"]).optional(),
amountPayed: z
.number()
.positive("El monto pagado debe ser mayor a 0")
.optional(),
paymentDate: z
.string()
.datetime({ message: "La fecha debe ser una fecha ISO válida" })
.optional(),
}); });
function lastDayOfMonth(year: number, month: number): number { function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate(); return new Date(year, month, 0).getDate();
} }
export function buildDueDate(year: number, month: number, dueDay: number): Date { export function buildDueDate(
year: number,
month: number,
dueDay: number,
): Date {
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month)); const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0)); return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
} }
@@ -36,33 +62,106 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } {
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 }; return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
} }
function isSameDay(a: Date, b: Date): boolean {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth() &&
a.getUTCDate() === b.getUTCDate()
);
}
export async function getBeloSellPriceForDate(
paymentDate: Date,
): Promise<number | null> {
const now = new Date();
if (isSameDay(paymentDate, now)) {
const quote = await prisma.quote.findFirst({
where: { type: QuoteType.BELO },
});
return quote ? Number(quote.sell) : null;
}
const target = new Date(
Date.UTC(
paymentDate.getUTCFullYear(),
paymentDate.getUTCMonth(),
paymentDate.getUTCDate(),
17, 0, 0, 0,
),
);
const before = await prisma.quoteHistory.findFirst({
where: {
type: QuoteType.BELO,
timeStamp: { lte: target },
},
orderBy: { timeStamp: "desc" },
});
const after = await prisma.quoteHistory.findFirst({
where: {
type: QuoteType.BELO,
timeStamp: { gte: target },
},
orderBy: { timeStamp: "asc" },
});
if (before && after) {
const beforeDiff = Math.abs(before.timeStamp.getTime() - target.getTime());
const afterDiff = Math.abs(after.timeStamp.getTime() - target.getTime());
return beforeDiff <= afterDiff ? Number(before.sell) : Number(after.sell);
}
if (before) return Number(before.sell);
if (after) return Number(after.sell);
return null;
}
export async function listPeriodicExpenses() { export async function listPeriodicExpenses() {
return prisma.periodicExpense.findMany({ const cached = cache.get("expenses:periodic");
if (cached) return cached;
const data = await prisma.periodicExpense.findMany({
where: { isDeleted: false }, where: { isDeleted: false },
orderBy: { description: "asc" }, orderBy: { description: "asc" },
}); });
cache.set("expenses:periodic", data);
return data;
} }
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) { export async function createPeriodicExpense(
data: z.infer<typeof createPeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.create({ data }); const result = await prisma.periodicExpense.create({ data });
const { year, month } = getCurrentYearMonthUTC(); const { month } = getCurrentYearMonthUTC();
const currentMonthApplicable = data.periods.includes(month); const currentMonthApplicable = data.periods.includes(month);
cache.invalidateByPrefix("expenses:");
return { ...result, currentMonthApplicable }; return { ...result, currentMonthApplicable };
} }
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) { export async function updatePeriodicExpense(
return prisma.periodicExpense.update({ where: { id }, data }); id: number,
data: z.infer<typeof updatePeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.update({ where: { id }, data });
cache.invalidateByPrefix("expenses:");
return result;
} }
export async function softDeletePeriodicExpense(id: number) { export async function softDeletePeriodicExpense(id: number) {
return prisma.periodicExpense.update({ const result = await prisma.periodicExpense.update({
where: { id }, where: { id },
data: { isDeleted: true }, data: { isDeleted: true },
}); });
cache.invalidateByPrefix("expenses:");
return result;
} }
export async function generateMonthlyExpense(id: number) { export async function generateMonthlyExpense(id: number) {
const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } }); const periodic = await prisma.periodicExpense.findUniqueOrThrow({
where: { id },
});
const { year, month } = getCurrentYearMonthUTC(); const { year, month } = getCurrentYearMonthUTC();
const existing = await prisma.expense.findFirst({ const existing = await prisma.expense.findFirst({
@@ -71,7 +170,7 @@ export async function generateMonthlyExpense(id: number) {
if (existing) return existing; if (existing) return existing;
const dueDate = buildDueDate(year, month, periodic.defaultDueDay); const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
return prisma.expense.create({ const result = await prisma.expense.create({
data: { data: {
description: periodic.description, description: periodic.description,
periodicExpenseId: id, periodicExpenseId: id,
@@ -82,6 +181,8 @@ export async function generateMonthlyExpense(id: number) {
status: PaymentStatus.PENDING, status: PaymentStatus.PENDING,
}, },
}); });
cache.invalidateByPrefix("expenses:");
return result;
} }
export async function listExpenses(params: { export async function listExpenses(params: {
@@ -92,6 +193,11 @@ export async function listExpenses(params: {
search?: string; search?: string;
}) { }) {
const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params; const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params;
const cacheKey = `expenses:list:${status ?? "all"}:${page}:${pageSize}:${periodicExpenseId ?? "none"}:${search ?? ""}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (status === "PENDING" || status === "PAYED") { if (status === "PENDING" || status === "PAYED") {
where.status = status; where.status = status;
@@ -112,15 +218,39 @@ export async function listExpenses(params: {
}), }),
prisma.expense.count({ where }), prisma.expense.count({ where }),
]); ]);
return { data, total, page, pageSize }; const result = { data, total, page, pageSize };
cache.set(cacheKey, result);
return result;
} }
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) { export async function createNonPeriodicExpense(
data: z.infer<typeof createNonPeriodicExpenseSchema>,
) {
const dueDate = new Date(data.dueDate); const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; const month = dueDate.getUTCMonth() + 1;
return prisma.expense.create({
data: { const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate);
const movementAmount =
isUSDC && data.usdcConversionRate
? -roundTo(data.amount / data.usdcConversionRate, 4)
: -data.amount;
const expenseData: Prisma.ExpenseUncheckedCreateInput = {
description: data.description, description: data.description,
amount: data.amount, amount: data.amount,
amountPayed: data.amount, amountPayed: data.amount,
@@ -129,55 +259,300 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
year, year,
month, month,
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
walletId: data.walletId,
};
if (isUSDC && data.usdcConversionRate) {
expenseData.beloPrice = data.usdcConversionRate;
expenseData.usdcEquivalent = roundTo(
data.amount / data.usdcConversionRate,
6,
);
} else if (belo !== null) {
expenseData.beloPrice = belo;
expenseData.usdcEquivalent = roundTo(data.amount / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.create({ data: expenseData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${data.description}`,
}, },
}); }),
]);
cache.invalidateByPrefix("expenses:");
return result;
} }
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) { export async function payExpense(
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); id: number,
return prisma.expense.update({ data: z.infer<typeof payExpenseSchema>,
where: { id }, ) {
data: { const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
const paymentDate = data.paymentDate
? new Date(data.paymentDate)
: new Date();
const wallet = await prisma.wallet.findUnique({
where: { id: data.walletId },
});
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const isUSDC = wallet.currency === "USDC";
if (isUSDC && !data.usdcConversionRate) {
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
}
const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate);
const movementAmount = isUSDC && data.usdcConversionRate
? -roundTo(data.amountPayed / data.usdcConversionRate, 4)
: -data.amountPayed;
const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = {
status: PaymentStatus.PAYED, status: PaymentStatus.PAYED,
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate, paymentDate,
walletId: data.walletId,
};
if (isUSDC && data.usdcConversionRate) {
expenseUpdateData.beloPrice = data.usdcConversionRate;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6);
} else if (belo !== null) {
expenseUpdateData.beloPrice = belo;
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6);
}
const [result] = await prisma.$transaction([
prisma.expense.update({ where: { id }, data: expenseUpdateData }),
prisma.wallet.update({
where: { id: data.walletId },
data: { balance: { increment: movementAmount } },
}),
prisma.walletMovement.create({
data: {
walletId: data.walletId,
type: "WITHDRAWAL",
amount: movementAmount,
description: `Pago: ${expense.description}`,
}, },
}),
]);
cache.invalidateByPrefix("expenses:");
return result;
}
export async function updateExpense(
id: number,
data: z.infer<typeof updateExpenseSchema>,
) {
const existing = await prisma.expense.findUniqueOrThrow({ where: { id } });
const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
const updateData: Prisma.ExpenseUncheckedUpdateInput = {
amount: data.amount,
dueDate,
year,
month,
};
let walletAdjustment: {
walletId: number;
amount: number;
description: string;
} | null = null;
if (data.status === "PENDING") {
updateData.status = PaymentStatus.PENDING;
updateData.amountPayed = null;
updateData.paymentDate = null;
updateData.beloPrice = null;
updateData.usdcEquivalent = null;
if (existing.status === PaymentStatus.PAYED && existing.walletId) {
const refundAmount = existing.amountPayed
? Number(existing.amountPayed)
: Number(existing.amount);
walletAdjustment = {
walletId: existing.walletId,
amount: refundAmount,
description: "Anulación de gasto",
};
}
} else if (existing.status === PaymentStatus.PAYED) {
if (data.amountPayed !== undefined) {
updateData.amountPayed = data.amountPayed;
}
if (data.paymentDate !== undefined) {
updateData.paymentDate = new Date(data.paymentDate);
}
const resolvedPaymentDate = updateData.paymentDate instanceof Date
? updateData.paymentDate
: (existing.paymentDate ?? undefined);
const amountPayed = updateData.amountPayed ?? existing.amountPayed;
if (resolvedPaymentDate && amountPayed !== undefined && amountPayed !== null) {
const belo = await getBeloSellPriceForDate(resolvedPaymentDate);
if (belo !== null) {
updateData.beloPrice = belo;
updateData.usdcEquivalent = roundTo(Number(amountPayed) / belo, 6);
} else {
updateData.beloPrice = null;
updateData.usdcEquivalent = null;
}
}
if (existing.walletId) {
const oldPayed = existing.amountPayed
? Number(existing.amountPayed)
: Number(existing.amount);
const newPayed = data.amountPayed !== undefined
? Number(data.amountPayed)
: Number(data.amount);
if (newPayed !== oldPayed) {
const difference = newPayed - oldPayed;
walletAdjustment = {
walletId: existing.walletId,
amount: -difference,
description: "Ajuste por edición de gasto",
};
}
}
}
if (walletAdjustment) {
const wallet = await prisma.wallet.findUnique({
where: { id: walletAdjustment.walletId },
}); });
if (!wallet) {
throw new Error("Billetera no encontrada");
}
const [result] = await prisma.$transaction([
prisma.expense.update({ where: { id }, data: updateData }),
prisma.wallet.update({
where: { id: walletAdjustment.walletId },
data: { balance: { increment: walletAdjustment.amount } },
}),
prisma.walletMovement.create({
data: {
walletId: walletAdjustment.walletId,
type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL",
amount: walletAdjustment.amount,
description: walletAdjustment.description,
},
}),
]);
cache.invalidateByPrefix("expenses:");
return result;
}
const result = await prisma.expense.update({
where: { id },
data: updateData,
});
cache.invalidateByPrefix("expenses:");
return result;
} }
export async function getMonthlyPayedTotal(year: number, month: number) { export async function getMonthlyPayedTotal(year: number, month: number) {
const expenses = await prisma.expense.findMany({ const cacheKey = `expenses:monthly-total:${year}:${month}`;
where: { year, month, status: PaymentStatus.PAYED }, const cached = cache.get(cacheKey);
select: { amountPayed: true }, if (cached) return cached;
});
const total = expenses.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0); const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
return { total }; SELECT CAST(SUM("amountPayed") AS NUMERIC) as total
FROM expenses
WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus"
`;
const data = { total: Number(result[0]?.total ?? 0) };
cache.set(cacheKey, data);
return data;
} }
export async function getMonthlyTotals(year: number, month: number) { export async function getMonthlyTotals(year: number, month: number) {
const [payed, pending] = await Promise.all([ const cacheKey = `expenses:totals:${year}:${month}`;
prisma.expense.findMany({ const cached = cache.get(cacheKey);
where: { year, month, status: PaymentStatus.PAYED }, if (cached) return cached;
select: { amountPayed: true },
}), const result = await prisma.$queryRaw<
prisma.expense.findMany({ Array<{ payed: string | null; pending: string | null }>
where: { year, month, status: PaymentStatus.PENDING }, >`
select: { amount: true }, SELECT
}), (SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
]); (SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
return { `;
payed: payed.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0), const data = {
pending: pending.reduce((sum, e) => sum + Number(e.amount), 0), payed: Number(result[0]?.payed ?? 0),
pending: Number(result[0]?.pending ?? 0),
}; };
cache.set(cacheKey, data);
return data;
} }
export async function getTotalPending() { export async function getTotalPending() {
const expenses = await prisma.expense.findMany({ const cached = cache.get("expenses:pending-total");
where: { status: PaymentStatus.PENDING }, if (cached) return cached;
select: { amount: true },
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
SELECT CAST(SUM(amount) AS NUMERIC) as total
FROM expenses
WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus"
`;
const data = { total: Number(result[0]?.total ?? 0) };
cache.set("expenses:pending-total", data);
return data;
}
export async function listPendingUpcomingExpenses() {
const now = new Date();
const today = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0,
0,
0,
0,
),
);
const fiveDaysLater = new Date(today);
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
const data = await prisma.expense.findMany({
where: {
status: PaymentStatus.PENDING,
dueDate: { lte: fiveDaysLater },
},
include: { periodicExpense: true },
orderBy: { dueDate: "asc" },
}); });
const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0);
return { total }; return data.map((expense) => ({
...expense,
dueType:
expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const),
}));
} }
export async function generateExpensesForCurrentMonth() { export async function generateExpensesForCurrentMonth() {
@@ -210,5 +585,6 @@ export async function generateExpensesForCurrentMonth() {
}); });
results.push({ id: periodic.id, status: "created" }); results.push({ id: periodic.id, status: "created" });
} }
cache.invalidateByPrefix("expenses:");
return results; return results;
} }

View File

@@ -1,5 +1,8 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { createNonPeriodicExpense, createNonPeriodicExpenseSchema } from "../expenses.service"; import {
createNonPeriodicExpense,
createNonPeriodicExpenseSchema,
} from "../expenses.service";
export async function createNonPeriodicExpenseHandler(c: Context) { export async function createNonPeriodicExpenseHandler(c: Context) {
const body = await c.req.json(); const body = await c.req.json();

View File

@@ -1,5 +1,8 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { createPeriodicExpense, createPeriodicExpenseSchema } from "../expenses.service"; import {
createPeriodicExpense,
createPeriodicExpenseSchema,
} from "../expenses.service";
export async function createPeriodicExpenseHandler(c: Context) { export async function createPeriodicExpenseHandler(c: Context) {
const body = await c.req.json(); const body = await c.req.json();

View File

@@ -2,8 +2,14 @@ import type { Context } from "hono";
import { getMonthlyPayedTotal } from "../expenses.service"; import { getMonthlyPayedTotal } from "../expenses.service";
export async function getMonthlyPayedTotalHandler(c: Context) { export async function getMonthlyPayedTotalHandler(c: Context) {
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10); const year = parseInt(
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10); c.req.query("year") ?? String(new Date().getUTCFullYear()),
10,
);
const month = parseInt(
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
10,
);
const result = await getMonthlyPayedTotal(year, month); const result = await getMonthlyPayedTotal(year, month);
return c.json(result); return c.json(result);
} }

View File

@@ -2,8 +2,14 @@ import type { Context } from "hono";
import { getMonthlyTotals } from "../expenses.service"; import { getMonthlyTotals } from "../expenses.service";
export async function getMonthlyTotalsHandler(c: Context) { export async function getMonthlyTotalsHandler(c: Context) {
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10); const year = parseInt(
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10); c.req.query("year") ?? String(new Date().getUTCFullYear()),
10,
);
const month = parseInt(
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
10,
);
const result = await getMonthlyTotals(year, month); const result = await getMonthlyTotals(year, month);
return c.json(result); return c.json(result);
} }

View File

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

View File

@@ -1,25 +1,26 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { logger } from "../../../lib/logger";
import { PaymentStatus } from "../../../generated/prisma/client"; import { PaymentStatus } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { logger } from "../../../lib/logger";
import { prisma } from "../../../lib/prisma";
function parseAmount(raw: string): number | null { function parseAmount(raw: string): number | null {
const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
if (!cleaned) return null; if (!cleaned) return null;
const n = Number(cleaned); const n = Number(cleaned);
return isNaN(n) ? null : n; return Number.isNaN(n) ? null : n;
} }
function parseDate(raw: string): Date | null { function parseDate(raw: string): Date | null {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return null; if (!trimmed) return null;
const d = new Date(trimmed); const d = new Date(trimmed);
return isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
export async function importExpensesHandler(c: Context) { export async function importExpensesHandler(c: Context) {
const body = await c.req.parseBody(); const body = await c.req.parseBody();
const file = body["file"]; const file = body.file;
if (!file || !(file instanceof File)) { if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400); return c.json({ error: "No se envió ningún archivo" }, 400);
@@ -29,7 +30,10 @@ export async function importExpensesHandler(c: Context) {
const lines = text.trim().split("\n"); const lines = text.trim().split("\n");
if (lines.length < 2) { if (lines.length < 2) {
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); return c.json(
{ error: "El archivo está vacío o solo tiene encabezados" },
400,
);
} }
let imported = 0; let imported = 0;
@@ -42,7 +46,9 @@ export async function importExpensesHandler(c: Context) {
const parts = line.split("|"); const parts = line.split("|");
if (parts.length < 10) { if (parts.length < 10) {
errors.push(`Línea ${i + 1}: formato inválido (${parts.length} columnas)`); errors.push(
`Línea ${i + 1}: formato inválido (${parts.length} columnas)`,
);
continue; continue;
} }
@@ -67,7 +73,9 @@ export async function importExpensesHandler(c: Context) {
const paymentDate = parseDate(rawPaymentDate); const paymentDate = parseDate(rawPaymentDate);
if (!dueDate) { if (!dueDate) {
errors.push(`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`); errors.push(
`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`,
);
continue; continue;
} }
@@ -89,7 +97,8 @@ export async function importExpensesHandler(c: Context) {
} }
} }
const status = rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING; const status =
rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
await prisma.expense.create({ await prisma.expense.create({
data: { data: {
@@ -113,6 +122,8 @@ export async function importExpensesHandler(c: Context) {
} }
} }
cache.invalidateByPrefix("expenses:");
return c.json({ return c.json({
imported, imported,
skipped, skipped,

View File

@@ -1,6 +1,7 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma"; import { cache } from "../../../lib/cache";
import { logger } from "../../../lib/logger"; import { logger } from "../../../lib/logger";
import { prisma } from "../../../lib/prisma";
function parseArgentineAmount(raw: string): number { function parseArgentineAmount(raw: string): number {
const cleaned = raw.replace("$", "").replace(/,/g, "").trim(); const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
@@ -15,7 +16,7 @@ function parsePeriods(raw: string): number[] {
export async function importPeriodicExpensesHandler(c: Context) { export async function importPeriodicExpensesHandler(c: Context) {
const body = await c.req.parseBody(); const body = await c.req.parseBody();
const file = body["file"]; const file = body.file;
if (!file || !(file instanceof File)) { if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400); return c.json({ error: "No se envió ningún archivo" }, 400);
@@ -25,7 +26,10 @@ export async function importPeriodicExpensesHandler(c: Context) {
const lines = text.trim().split("\n"); const lines = text.trim().split("\n");
if (lines.length < 2) { if (lines.length < 2) {
return c.json({ error: "El archivo está vacío o solo tiene encabezados" }, 400); return c.json(
{ error: "El archivo está vacío o solo tiene encabezados" },
400,
);
} }
let imported = 0; let imported = 0;
@@ -70,8 +74,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
await prisma.periodicExpense.create({ await prisma.periodicExpense.create({
data: { data: {
description, description,
defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay, defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay,
defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount, defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount,
periods, periods,
}, },
}); });
@@ -84,6 +88,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
} }
} }
cache.invalidateByPrefix("expenses:");
return c.json({ return c.json({
imported, imported,
skipped: skippedNoPeriods + skippedDuplicate, skipped: skippedNoPeriods + skippedDuplicate,

View File

@@ -11,7 +11,9 @@ export async function listExpensesHandler(c: Context) {
status, status,
page, page,
pageSize, pageSize,
periodicExpenseId: periodicExpenseId ? parseInt(periodicExpenseId, 10) : undefined, periodicExpenseId: periodicExpenseId
? parseInt(periodicExpenseId, 10)
: undefined,
search: search || undefined, search: search || undefined,
}); });
return c.json(result); return c.json(result);

View File

@@ -0,0 +1,10 @@
import type { Context } from "hono";
import { updateExpense, updateExpenseSchema } from "../expenses.service";
export async function updateExpenseHandler(c: Context) {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const parsed = updateExpenseSchema.parse(body);
const result = await updateExpense(id, parsed);
return c.json(result);
}

View File

@@ -1,5 +1,8 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { updatePeriodicExpense, updatePeriodicExpenseSchema } from "../expenses.service"; import {
updatePeriodicExpense,
updatePeriodicExpenseSchema,
} from "../expenses.service";
export async function updatePeriodicExpenseHandler(c: Context) { export async function updatePeriodicExpenseHandler(c: Context) {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));

View File

@@ -45,7 +45,10 @@ export async function processBeloQuote(): Promise<void> {
const diff = roundTo(sell - existingSell, 2); const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BELO`); logger.debug(
{ buy, sell, diferencia: diff, porcentaje: pct },
`Cotización BELO`,
);
const todayStart = startOfToday(); const todayStart = startOfToday();
@@ -62,7 +65,8 @@ export async function processBeloQuote(): Promise<void> {
if (prevDayHistory) { if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell); const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2); prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; prevDayPct =
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
} }
await prisma.quote.update({ await prisma.quote.update({

View File

@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
} }
export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> { export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", { const response = await fetch(
"https://api.dolarito.ar/api/frontend/quotations/dolar",
{
credentials: "omit", credentials: "omit",
headers: { headers: {
"User-Agent": "User-Agent":
@@ -25,7 +27,8 @@ export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
referrer: "https://www.dolarito.ar/", referrer: "https://www.dolarito.ar/",
method: "GET", method: "GET",
mode: "cors", mode: "cors",
}); },
);
const data: DolaritoApiResponse = await response.json(); const data: DolaritoApiResponse = await response.json();
return { buy: data.informal.buy, sell: data.informal.sell }; return { buy: data.informal.buy, sell: data.informal.sell };
} }
@@ -59,7 +62,10 @@ export async function processBlueQuote(): Promise<void> {
const diff = roundTo(sell - existingSell, 2); const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`); logger.debug(
{ buy, sell, diferencia: diff, porcentaje: pct },
`Cotización BLUE`,
);
const todayStart = startOfToday(); const todayStart = startOfToday();
@@ -76,7 +82,8 @@ export async function processBlueQuote(): Promise<void> {
if (prevDayHistory) { if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell); const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2); prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; prevDayPct =
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
} }
await prisma.quote.update({ await prisma.quote.update({

View File

@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
} }
export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> { export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
const response = await fetch("https://api.dolarito.ar/api/frontend/quotations/dolar", { const response = await fetch(
"https://api.dolarito.ar/api/frontend/quotations/dolar",
{
credentials: "omit", credentials: "omit",
headers: { headers: {
"User-Agent": "User-Agent":
@@ -25,7 +27,8 @@ export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
referrer: "https://www.dolarito.ar/", referrer: "https://www.dolarito.ar/",
method: "GET", method: "GET",
mode: "cors", mode: "cors",
}); },
);
const data: DolaritoApiResponse = await response.json(); const data: DolaritoApiResponse = await response.json();
return { buy: data.oficial.buy, sell: data.oficial.sell }; return { buy: data.oficial.buy, sell: data.oficial.sell };
} }
@@ -59,7 +62,10 @@ export async function processBnaQuote(): Promise<void> {
const diff = roundTo(sell - existingSell, 2); const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`); logger.debug(
{ buy, sell, diferencia: diff, porcentaje: pct },
`Cotización BNA`,
);
const todayStart = startOfToday(); const todayStart = startOfToday();
@@ -76,7 +82,8 @@ export async function processBnaQuote(): Promise<void> {
if (prevDayHistory) { if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell); const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2); prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; prevDayPct =
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
} }
await prisma.quote.update({ await prisma.quote.update({

View File

@@ -1,14 +1,18 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { eventBus } from "../../../lib/event-bus";
import { logger } from "../../../lib/logger";
import { processBeloQuote } from "../belo.service"; import { processBeloQuote } from "../belo.service";
import { processBlueQuote } from "../blue.service"; import { processBlueQuote } from "../blue.service";
import { processBnaQuote } from "../bna.service"; import { processBnaQuote } from "../bna.service";
import { logger } from "../../../lib/logger";
import { eventBus } from "../../../lib/event-bus";
export async function fetchQuotes(c: Context) { export async function fetchQuotes(c: Context) {
const results: { type: string; status: string; error?: string }[] = []; const results: { type: string; status: string; error?: string }[] = [];
for (const [name, fn] of [["BELO", processBeloQuote], ["BLUE", processBlueQuote], ["BNA", processBnaQuote]] as const) { for (const [name, fn] of [
["BELO", processBeloQuote],
["BLUE", processBlueQuote],
["BNA", processBnaQuote],
] as const) {
try { try {
await fn(); await fn();
results.push({ type: name, status: "ok" }); results.push({ type: name, status: "ok" });

View File

@@ -1,7 +1,12 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
export async function getCurrentQuotes(c: Context) { export async function getCurrentQuotes(c: Context) {
const cached = cache.get("quotes:current");
if (cached) return c.json(cached);
const quotes = await prisma.quote.findMany(); const quotes = await prisma.quote.findMany();
cache.set("quotes:current", quotes);
return c.json(quotes); return c.json(quotes);
} }

View File

@@ -1,14 +1,21 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import type { QuoteType } from "../../../generated/prisma/client"; import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getDailyMinMax(c: Context) { export async function getDailyMinMax(c: Context) {
const rawType = c.req.param("type")!.toUpperCase(); const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
} }
const startDateParam = c.req.query("startDate"); const startDateParam = c.req.query("startDate");
@@ -16,23 +23,27 @@ export async function getDailyMinMax(c: Context) {
const now = new Date(); const now = new Date();
const startDate = startDateParam const startDate = startDateParam
? new Date(startDateParam) ? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1); : new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
} }
const rows = await prisma.$queryRaw<Array<{ const cacheKey = `quotes:minmax:${rawType}:${startDate.toISOString()}:${endDate.toISOString()}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<
Array<{
date: string; date: string;
minBuy: number; minBuy: number;
maxBuy: number; maxBuy: number;
minSell: number; minSell: number;
maxSell: number; maxSell: number;
}>>` }>
>`
SELECT SELECT
DATE("timeStamp")::text AS date, DATE("timeStamp")::text AS date,
MIN(buy::numeric)::float8 AS "minBuy", MIN(buy::numeric)::float8 AS "minBuy",
@@ -67,5 +78,6 @@ export async function getDailyMinMax(c: Context) {
} }
} }
cache.set(cacheKey, rows);
return c.json(rows); return c.json(rows);
} }

View File

@@ -1,13 +1,20 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getDailyQuotes(c: Context) { export async function getDailyQuotes(c: Context) {
const rawType = c.req.param("type")!.toUpperCase(); const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
} }
const startDateParam = c.req.query("startDate"); const startDateParam = c.req.query("startDate");
@@ -15,21 +22,25 @@ export async function getDailyQuotes(c: Context) {
const now = new Date(); const now = new Date();
const startDate = startDateParam const startDate = startDateParam
? new Date(startDateParam) ? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1); : new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
} }
const rows = await prisma.$queryRaw<Array<{ const cacheKey = `quotes:daily:${rawType}:${startDate.toISOString()}:${endDate.toISOString()}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<
Array<{
date: string; date: string;
buy: number; buy: number;
sell: number; sell: number;
}>>` }>
>`
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM ( SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
FROM "quotes_history" FROM "quotes_history"
@@ -41,5 +52,6 @@ export async function getDailyQuotes(c: Context) {
ORDER BY DATE("timeStamp") ASC ORDER BY DATE("timeStamp") ASC
`; `;
cache.set(cacheKey, rows);
return c.json(rows); return c.json(rows);
} }

View File

@@ -0,0 +1,49 @@
import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getHistoricalMinMax(c: Context) {
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const cacheKey = `quotes:historical:minmax:${rawType}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const type = rawType as string;
const rows = await prisma.$queryRaw<
Array<{
minBuy: number;
maxBuy: number;
minBuyDate: string;
maxBuyDate: string;
}>
>`
SELECT
(SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy",
(SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy",
(SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric ASC LIMIT 1) AS "minBuyDate",
(SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate"
`;
const result = rows[0] ?? {
minBuy: 0,
maxBuy: 0,
minBuyDate: null,
maxBuyDate: null,
};
cache.set(cacheKey, result);
return c.json(result);
}

View File

@@ -1,14 +1,21 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import type { QuoteType } from "../../../generated/prisma/client"; import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getQuoteHistory(c: Context) { export async function getQuoteHistory(c: Context) {
const rawType = c.req.param("type")!.toUpperCase(); const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) { if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400); return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
} }
const type = rawType as QuoteType; const type = rawType as QuoteType;
@@ -17,16 +24,18 @@ export async function getQuoteHistory(c: Context) {
const now = new Date(); const now = new Date();
const startDate = startDateParam const startDate = startDateParam
? new Date(startDateParam) ? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1); : new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
} }
const cacheKey = `quotes:history:${type}:${startDate.toISOString()}:${endDate.toISOString()}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const history = await prisma.quoteHistory.findMany({ const history = await prisma.quoteHistory.findMany({
where: { where: {
type, type,
@@ -35,5 +44,6 @@ export async function getQuoteHistory(c: Context) {
orderBy: { timeStamp: "asc" }, orderBy: { timeStamp: "asc" },
}); });
cache.set(cacheKey, history);
return c.json(history); return c.json(history);
} }

View File

@@ -8,7 +8,9 @@ export function quoteEvents(c: Context) {
function cleanup() { function cleanup() {
if (isCancelled) return; if (isCancelled) return;
isCancelled = true; isCancelled = true;
cleanups.forEach((fn) => fn()); cleanups.forEach((fn) => {
fn();
});
} }
const stream = new ReadableStream({ const stream = new ReadableStream({
@@ -43,7 +45,7 @@ export function quoteEvents(c: Context) {
headers: { headers: {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", Connection: "keep-alive",
}, },
}); });
} }

View File

@@ -1,9 +1,10 @@
import cron from "node-cron"; import cron from "node-cron";
import { eventBus } from "../../lib/event-bus";
import { logger } from "../../lib/logger";
import { checkAndNotifyBeloMax } from "../telegram/telegram.service";
import { processBeloQuote } from "./belo.service"; import { processBeloQuote } from "./belo.service";
import { processBlueQuote } from "./blue.service"; import { processBlueQuote } from "./blue.service";
import { processBnaQuote } from "./bna.service"; import { processBnaQuote } from "./bna.service";
import { logger } from "../../lib/logger";
import { eventBus } from "../../lib/event-bus";
async function safeProcess( async function safeProcess(
name: string, name: string,
@@ -12,6 +13,9 @@ async function safeProcess(
try { try {
await fn(); await fn();
logger.info(`${name} quote processed successfully`); logger.info(`${name} quote processed successfully`);
if (name === "BELO") {
await checkAndNotifyBeloMax();
}
} catch (error) { } catch (error) {
logger.error(error, `Error processing ${name} quote`); logger.error(error, `Error processing ${name} quote`);
} }

View File

@@ -1,9 +1,10 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
import { fetchQuotes } from "./handlers/fetchQuotes"; import { fetchQuotes } from "./handlers/fetchQuotes";
import { getQuoteHistory } from "./handlers/getQuoteHistory"; import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getDailyMinMax } from "./handlers/getDailyMinMax"; import { getDailyMinMax } from "./handlers/getDailyMinMax";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { quoteEvents } from "./handlers/quoteEvents"; import { quoteEvents } from "./handlers/quoteEvents";
const app = new Hono(); const app = new Hono();
@@ -12,6 +13,7 @@ app.get("/", getCurrentQuotes);
app.get("/:type/history", getQuoteHistory); app.get("/:type/history", getQuoteHistory);
app.get("/:type/daily", getDailyQuotes); app.get("/:type/daily", getDailyQuotes);
app.get("/:type/min-max", getDailyMinMax); app.get("/:type/min-max", getDailyMinMax);
app.get("/:type/historical/min-max", getHistoricalMinMax);
app.get("/fetch", fetchQuotes); app.get("/fetch", fetchQuotes);
app.get("/events", quoteEvents); app.get("/events", quoteEvents);

View File

@@ -0,0 +1,20 @@
import type { Context } from "hono";
import { generateCode, getTelegramStatus } from "./telegram.service";
export async function generateCodeHandler(c: Context) {
try {
const result = await generateCode();
return c.json(result);
} catch (error) {
return c.json({ error: "Failed to generate code" }, 500);
}
}
export async function telegramStatusHandler(c: Context) {
try {
const status = await getTelegramStatus();
return c.json(status);
} catch (error) {
return c.json({ connected: false, botUsername: null }, 500);
}
}

View File

@@ -0,0 +1,9 @@
import { Hono } from "hono";
import { generateCodeHandler, telegramStatusHandler } from "./telegram.handler";
const app = new Hono();
app.post("/code", generateCodeHandler);
app.get("/status", telegramStatusHandler);
export default app;

View File

@@ -0,0 +1,151 @@
import crypto from "crypto";
import { prisma } from "../../lib/prisma";
import { logger } from "../../lib/logger";
import { getBot, getBotUsername } from "../../lib/telegram";
function generateRandomCode(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const bytes = crypto.randomBytes(8);
let code = "";
for (let i = 0; i < 8; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
function formatPrice(price: number): string {
return price.toLocaleString("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export async function generateCode(): Promise<{
code: string;
botUsername: string | null;
}> {
const code = generateRandomCode();
await prisma.telegramChat.create({
data: { code },
});
return { code, botUsername: getBotUsername() };
}
export async function validateCode(
chatId: bigint,
code: string,
): Promise<boolean> {
const record = await prisma.telegramChat.findUnique({
where: { code },
});
if (!record || record.validatedAt !== null) return false;
await prisma.telegramChat.deleteMany({
where: { chatId, validatedAt: { not: null } },
});
await prisma.telegramChat.update({
where: { id: record.id },
data: {
chatId,
validatedAt: new Date(),
},
});
return true;
}
export async function getTelegramStatus(): Promise<{
botUsername: string | null;
connected: boolean;
}> {
const validated = await prisma.telegramChat.findFirst({
where: { validatedAt: { not: null } },
});
return {
botUsername: getBotUsername(),
connected: validated !== null,
};
}
export async function checkAndNotifyBeloMax(): Promise<void> {
const chat = await prisma.telegramChat.findFirst({
where: { validatedAt: { not: null } },
orderBy: { validatedAt: "desc" },
});
if (!chat?.chatId) return;
const bot = getBot();
if (!bot) return;
const now = new Date();
const artHour = ((now.getUTCHours() - 3) % 24 + 24) % 24;
if (artHour < 8 || artHour >= 20) return;
const currentQuote = await prisma.quote.findFirst({
where: { type: "BELO" },
orderBy: { timeStamp: "desc" },
});
if (!currentQuote) return;
const currentBuy = Number(currentQuote.buy);
const artOffset = -3;
const artMs = now.getTime() + artOffset * 3600000;
const artDate = new Date(artMs);
const todayStart = new Date(
Date.UTC(
artDate.getUTCFullYear(),
artDate.getUTCMonth(),
artDate.getUTCDate(),
),
);
const lastNotified = await prisma.telegramDailyMaxNotification.findUnique({
where: {
date_quoteType: {
date: todayStart,
quoteType: "BELO",
},
},
});
const lastNotifiedValue = lastNotified ? Number(lastNotified.maxValue) : 0;
if (currentBuy <= lastNotifiedValue) return;
const formattedPrice = formatPrice(currentBuy);
const message = `BELO alcanzó el máximo del día, la cotización actual es de $${formattedPrice}`;
try {
await bot.api.sendMessage(Number(chat.chatId), message);
await prisma.telegramDailyMaxNotification.upsert({
where: {
date_quoteType: {
date: todayStart,
quoteType: "BELO",
},
},
create: {
date: todayStart,
quoteType: "BELO",
maxValue: currentBuy,
},
update: {
maxValue: currentBuy,
},
});
logger.info(
`Telegram BELO max notification sent: $${formattedPrice}`,
);
} catch (error) {
logger.error(error, "Failed to send Telegram BELO max notification");
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import type { Context } from "hono";
import { listAllMovements } from "../wallets.service";
export async function listAllMovementsHandler(c: Context) {
const walletId = c.req.query("walletId")
? Number(c.req.query("walletId"))
: undefined;
const startDate = c.req.query("startDate") || undefined;
const endDate = c.req.query("endDate") || undefined;
const page = parseInt(c.req.query("page") ?? "1", 10);
const pageSize = parseInt(c.req.query("pageSize") ?? "20", 10);
const result = await listAllMovements({
walletId,
startDate,
endDate,
page,
pageSize,
});
return c.json(result);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
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 { listAllMovementsHandler } from "./handlers/listAllMovements";
import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet";
import { updateWalletHandler } from "./handlers/updateWallet";
import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
const app = new Hono();
app.get("/movements", listAllMovementsHandler);
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.put("/:id/default", setDefaultWalletHandler);
app.get("/:id/movements", listMovementsHandler);
export default app;

View File

@@ -0,0 +1,243 @@
import { z } from "zod";
import { Prisma } from "../../generated/prisma/client";
import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
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),
isDefault: z.boolean().optional().default(false),
});
export const updateWalletSchema = z.object({
name: z.string().min(1).max(100),
currency: z.string().min(1).max(10),
});
export const setDefaultWalletSchema = z.object({
walletId: z.number().int().positive(),
});
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 type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
export async function listWallets() {
const wallets = await prisma.wallet.findMany({
orderBy: [{ isDefault: "desc" }, { 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,
isDefault: input.isDefault ?? false,
},
});
if (wallet.isDefault) {
await prisma.wallet.updateMany({
where: { id: { not: wallet.id }, isDefault: true },
data: { isDefault: false },
});
}
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 setDefaultWallet(walletId: number) {
await prisma.$transaction([
prisma.wallet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
}),
prisma.wallet.update({
where: { id: walletId },
data: { isDefault: true },
}),
]);
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id: walletId } });
return { ...wallet, balance: Number(wallet.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) }));
}
export async function listAllMovements(params: {
walletId?: number;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}) {
const { walletId, startDate, endDate, page = 1, pageSize = 20 } = params;
const cacheKey = `movements:all:${walletId ?? "all"}:${startDate ?? ""}:${endDate ?? ""}:${page}:${pageSize}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const where: Record<string, unknown> = {};
if (walletId !== undefined) {
where.walletId = walletId;
}
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = new Date(startDate);
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
createdAt.lte = end;
}
where.createdAt = createdAt;
}
const [data, total] = await Promise.all([
prisma.walletMovement.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.walletMovement.count({ where }),
]);
const result = {
data: data.map((m) => ({ ...m, amount: Number(m.amount) })),
total,
page,
pageSize,
};
cache.set(cacheKey, result);
return result;
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Personal Admin</title> <title>Personal Admin</title>
<script> <script>
(function() { (() => {
var theme = localStorage.getItem("theme"); var theme = localStorage.getItem("theme");
if (theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches)) { if (theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");

View File

@@ -6,9 +6,12 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview",
"lint": "biome check src/",
"lint:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"better-auth": "^1.6.11",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@personal-admin/common": "workspace:*", "@personal-admin/common": "workspace:*",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",

View File

@@ -1,7 +1,7 @@
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
import { SseProvider } from "@/lib/sse-context";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import { SseProvider } from "@/lib/sse-context";
import { router } from "@/router";
function App() { function App() {
const auth = useAuth(); const auth = useAuth();

View File

@@ -1,8 +1,23 @@
import { Menu, Sun, Moon, Monitor, LogOut } from "lucide-react"; import { Link } from "@tanstack/react-router";
import { LogOut, Menu, Monitor, Moon, Sun } from "lucide-react";
function TelegramIcon(props: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
}
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth-context";
import { useSseStatus } from "@/lib/sse-context"; import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context"; import { useTelegramStatus } from "@/lib/queries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
@@ -13,6 +28,7 @@ export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus(); const sseStatus = useSseStatus();
const { theme, cycleTheme } = useTheme(); const { theme, cycleTheme } = useTheme();
const auth = useAuth(); const auth = useAuth();
const { data: telegramStatus } = useTelegramStatus();
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = const themeLabel =
@@ -24,7 +40,7 @@ export function Header({ onMenuClick }: HeaderProps) {
} }
return ( return (
<header className="flex h-14 items-center gap-4 border-b px-4"> <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -58,6 +74,18 @@ export function Header({ onMenuClick }: HeaderProps) {
> >
<ThemeIcon className="size-4" /> <ThemeIcon className="size-4" />
</Button> </Button>
<Link
to="/settings"
className={cn(
"inline-flex items-center justify-center rounded-md p-1.5 transition-colors",
telegramStatus?.connected
? "text-emerald-500 hover:text-emerald-400"
: "text-muted-foreground hover:text-foreground",
)}
title={telegramStatus?.connected ? "Telegram conectado" : "Conectar Telegram"}
>
<TelegramIcon className="size-4" />
</Link>
<span <span
className={cn( className={cn(
"size-2 rounded-full", "size-2 rounded-full",

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { Outlet } from "@tanstack/react-router"; import { Outlet } from "@tanstack/react-router";
import { useState } from "react";
import { LoadingBar } from "@/components/ui/loading-bar";
import { Header } from "./Header"; import { Header } from "./Header";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { LoadingBar } from "@/components/ui/loading-bar";
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -13,7 +13,7 @@ export function Layout() {
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<Header onMenuClick={() => setSidebarOpen(true)} /> <Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-6"> <main className="flex-1 px-3 py-4 sm:p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -1,10 +1,18 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { FileText, LayoutDashboard, Settings, TrendingUp, Wallet } from "lucide-react"; import {
FileText,
Landmark,
LayoutDashboard,
Settings,
TrendingUp,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/utils"; 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 },
]; ];
@@ -22,9 +30,11 @@ export function Sidebar({ open, onClose }: SidebarProps) {
return ( return (
<> <>
{open && ( {open && (
<div <button
className="fixed inset-0 z-40 bg-black/20 md:hidden" type="button"
className="fixed inset-0 z-40 bg-black/20 md:hidden cursor-default"
onClick={onClose} onClick={onClose}
aria-label="Cerrar menú"
/> />
)} )}
<aside <aside
@@ -43,7 +53,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }} activeProps={{
className:
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
}}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={onClose} onClick={onClose}
> >
@@ -57,7 +70,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }} activeProps={{
className:
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
}}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={onClose} onClick={onClose}
> >

View File

@@ -1,8 +1,8 @@
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui";
import { Slot } from "radix-ui" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -38,8 +38,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@@ -49,9 +49,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@@ -61,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,11 +1,15 @@
"use client" "use client";
import * as React from "react" import {
import { DayPicker } from "react-day-picker" ChevronDownIcon,
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react" ChevronLeftIcon,
import { cn } from "@/lib/utils" ChevronRightIcon,
} from "lucide-react";
import type * as React from "react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker> export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ function Calendar({
className, className,
@@ -37,7 +41,8 @@ function Calendar({
chevron: "size-3 text-muted-foreground", chevron: "size-3 text-muted-foreground",
month_grid: "w-full border-collapse", month_grid: "w-full border-collapse",
weekdays: "flex", weekdays: "flex",
weekday: "w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center", weekday:
"w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
week: "flex w-full", week: "flex w-full",
day: "p-0 size-8 text-center text-sm", day: "p-0 size-8 text-center text-sm",
day_button: day_button:
@@ -57,31 +62,37 @@ function Calendar({
}} }}
components={{ components={{
Chevron: (props) => { Chevron: (props) => {
const { orientation, ...rest } = props const { orientation, ...rest } = props;
if (orientation === "up" || orientation === "down") { if (orientation === "up" || orientation === "down") {
return <ChevronDownIcon className="size-3" {...rest} /> return <ChevronDownIcon className="size-3" {...rest} />;
} }
const Icon = orientation === "left" ? ChevronLeftIcon : ChevronRightIcon const Icon =
return <Icon className="size-4" {...rest} /> orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className="size-4" {...rest} />;
}, },
Select: (props) => { Select: (props) => {
const { className, children, ...rest } = props const { className, children, ...rest } = props;
return ( return (
<select <select
className={cn("h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat", className)} className={cn(
style={{ backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")` }} "h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat",
className,
)}
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")`,
}}
{...rest} {...rest}
> >
{children} {children}
</select> </select>
) );
}, },
}} }}
{...props} {...props}
/> />
) );
} }
Calendar.displayName = "Calendar" Calendar.displayName = "Calendar";
export { Calendar } export { Calendar };

View File

@@ -1,23 +1,22 @@
"use client" "use client";
import * as React from "react" import { format } from "date-fns";
import { format } from "date-fns" import { es } from "date-fns/locale";
import { es } from "date-fns/locale" import { CalendarIcon } from "lucide-react";
import { CalendarIcon } from "lucide-react" import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils" import { Calendar } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface DatePickerProps { interface DatePickerProps {
value: Date | undefined value: Date | undefined;
onChange: (date: Date | undefined) => void onChange: (date: Date | undefined) => void;
placeholder?: string placeholder?: string;
} }
export function DatePicker({ export function DatePicker({
@@ -25,7 +24,7 @@ export function DatePicker({
onChange, onChange,
placeholder = "Seleccionar fecha", placeholder = "Seleccionar fecha",
}: DatePickerProps) { }: DatePickerProps) {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false);
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
@@ -34,7 +33,7 @@ export function DatePicker({
variant="outline" variant="outline"
className={cn( className={cn(
"h-8 w-full justify-start gap-2 px-2.5 font-normal", "h-8 w-full justify-start gap-2 px-2.5 font-normal",
!value && "text-muted-foreground" !value && "text-muted-foreground",
)} )}
> >
<CalendarIcon className="size-4 shrink-0" /> <CalendarIcon className="size-4 shrink-0" />
@@ -52,11 +51,11 @@ export function DatePicker({
defaultMonth={value} defaultMonth={value}
captionLayout="dropdown" captionLayout="dropdown"
onSelect={(date) => { onSelect={(date) => {
onChange(date) onChange(date);
setOpen(false) setOpen(false);
}} }}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) );
} }

View File

@@ -1,6 +1,6 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -9,11 +9,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@@ -1,10 +1,12 @@
import { useState, useEffect, useRef } from "react";
import { useRouterState } from "@tanstack/react-router";
import { useIsFetching, useIsMutating } from "@tanstack/react-query"; import { useIsFetching, useIsMutating } from "@tanstack/react-query";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function LoadingBar() { export function LoadingBar() {
const isNavigating = useRouterState({ select: (s) => s.status === "pending" }); const isNavigating = useRouterState({
select: (s) => s.status === "pending",
});
const isFetching = useIsFetching(); const isFetching = useIsFetching();
const isMutating = useIsMutating(); const isMutating = useIsMutating();

View File

@@ -1,19 +1,21 @@
import * as React from "react" import {
ChevronLeftIcon,
import { cn } from "@/lib/utils" ChevronRightIcon,
import { Button } from "@/components/ui/button" MoreHorizontalIcon,
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" } from "lucide-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) { function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return ( return (
<nav <nav
role="navigation"
aria-label="pagination" aria-label="pagination"
data-slot="pagination" data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)} className={cn("mx-auto flex w-full justify-center", className)}
{...props} {...props}
/> />
) );
} }
function PaginationContent({ function PaginationContent({
@@ -26,17 +28,17 @@ function PaginationContent({
className={cn("flex items-center gap-0.5", className)} className={cn("flex items-center gap-0.5", className)}
{...props} {...props}
/> />
) );
} }
function PaginationItem({ ...props }: React.ComponentProps<"li">) { function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} /> return <li data-slot="pagination-item" {...props} />;
} }
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> & } & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a"> React.ComponentProps<"a">;
function PaginationLink({ function PaginationLink({
className, className,
@@ -58,7 +60,7 @@ function PaginationLink({
{...props} {...props}
/> />
</Button> </Button>
) );
} }
function PaginationPrevious({ function PaginationPrevious({
@@ -76,7 +78,7 @@ function PaginationPrevious({
<ChevronLeftIcon data-icon="inline-start" /> <ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span> <span className="hidden sm:block">{text}</span>
</PaginationLink> </PaginationLink>
) );
} }
function PaginationNext({ function PaginationNext({
@@ -94,7 +96,7 @@ function PaginationNext({
<span className="hidden sm:block">{text}</span> <span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" /> <ChevronRightIcon data-icon="inline-end" />
</PaginationLink> </PaginationLink>
) );
} }
function PaginationEllipsis({ function PaginationEllipsis({
@@ -107,15 +109,14 @@ function PaginationEllipsis({
data-slot="pagination-ellipsis" data-slot="pagination-ellipsis"
className={cn( className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4", "flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
<MoreHorizontalIcon <MoreHorizontalIcon />
/>
<span className="sr-only">More pages</span> <span className="sr-only">More pages</span>
</span> </span>
) );
} }
export { export {
@@ -126,4 +127,4 @@ export {
PaginationLink, PaginationLink,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} };

View File

@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as PopoverPrimitive from "@radix-ui/react-popover" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -37,12 +37,12 @@ function PopoverContent({
"data-[side=left]:slide-in-from-right-2", "data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2", "data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2", "data-[side=top]:slide-in-from-bottom-2",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverContent, PopoverTrigger };

View File

@@ -1,41 +1,45 @@
"use client" "use client";
import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import * as React from "react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react" import { cn } from "@/lib/utils";
const MEDIA_QUERY = "(min-width: 640px)" const MEDIA_QUERY = "(min-width: 640px)";
function useMediaQuery(query: string) { function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(() => { const [matches, setMatches] = React.useState(() => {
if (typeof window === "undefined") return true if (typeof window === "undefined") return true;
return window.matchMedia(query).matches return window.matchMedia(query).matches;
}) });
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(query) const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches) const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler) mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler) return () => mql.removeEventListener("change", handler);
}, [query]) }, [query]);
return matches return matches;
} }
interface ResponsiveDialogContextValue { interface ResponsiveDialogContextValue {
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
isDesktop: boolean isDesktop: boolean;
} }
const ResponsiveDialogContext = React.createContext<ResponsiveDialogContextValue | null>(null) const ResponsiveDialogContext =
React.createContext<ResponsiveDialogContextValue | null>(null);
function useResponsiveDialog() { function useResponsiveDialog() {
const ctx = React.useContext(ResponsiveDialogContext) const ctx = React.useContext(ResponsiveDialogContext);
if (!ctx) throw new Error("ResponsiveDialog components must be used within ResponsiveDialog") if (!ctx)
return ctx throw new Error(
"ResponsiveDialog components must be used within ResponsiveDialog",
);
return ctx;
} }
function ResponsiveDialog({ function ResponsiveDialog({
@@ -43,19 +47,22 @@ function ResponsiveDialog({
onOpenChange, onOpenChange,
children, children,
}: { }: {
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
children: React.ReactNode children: React.ReactNode;
}) { }) {
const [internalOpen, setInternalOpen] = React.useState(false) const [internalOpen, setInternalOpen] = React.useState(false);
const isDesktop = useMediaQuery(MEDIA_QUERY) const isDesktop = useMediaQuery(MEDIA_QUERY);
const controlled = open !== undefined const controlled = open !== undefined;
const currentOpen = controlled ? open : internalOpen const currentOpen = controlled ? open : internalOpen;
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen // biome-ignore lint/style/noNonNullAssertion: controlled ensures it's defined
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen;
return ( return (
<ResponsiveDialogContext value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}> <ResponsiveDialogContext
value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}
>
{isDesktop ? ( {isDesktop ? (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}> <DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children} {children}
@@ -66,7 +73,7 @@ function ResponsiveDialog({
</DialogPrimitive.Root> </DialogPrimitive.Root>
)} )}
</ResponsiveDialogContext> </ResponsiveDialogContext>
) );
} }
function ResponsiveDialogTrigger({ function ResponsiveDialogTrigger({
@@ -78,7 +85,7 @@ function ResponsiveDialogTrigger({
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}> <DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
{children} {children}
</DialogPrimitive.Trigger> </DialogPrimitive.Trigger>
) );
} }
function ResponsiveDialogContent({ function ResponsiveDialogContent({
@@ -86,7 +93,7 @@ function ResponsiveDialogContent({
children, children,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) { }: React.ComponentProps<typeof DialogPrimitive.Content>) {
const { isDesktop, open, onOpenChange } = useResponsiveDialog() const { isDesktop, open, onOpenChange } = useResponsiveDialog();
if (isDesktop) { if (isDesktop) {
return ( return (
@@ -103,7 +110,7 @@ function ResponsiveDialogContent({
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]", "data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]", "data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
"duration-200", "duration-200",
className className,
)} )}
{...props} {...props}
> >
@@ -120,22 +127,27 @@ function ResponsiveDialogContent({
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
) );
} }
if (!open) return null if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-end sm:hidden" data-slot="responsive-dialog-mobile">
<div <div
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs" className="fixed inset-0 z-50 flex items-end sm:hidden"
data-slot="responsive-dialog-mobile"
>
<button
type="button"
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs cursor-default"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
aria-label="Cerrar"
/> />
<div <div
className={cn( className={cn(
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg", "relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200", "animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
className className,
)} )}
> >
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" /> <div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
@@ -152,7 +164,7 @@ function ResponsiveDialogContent({
{children} {children}
</div> </div>
</div> </div>
) );
} }
function ResponsiveDialogHeader({ function ResponsiveDialogHeader({
@@ -165,7 +177,7 @@ function ResponsiveDialogHeader({
className={cn("flex flex-col gap-0.5", className)} className={cn("flex flex-col gap-0.5", className)}
{...props} {...props}
/> />
) );
} }
function ResponsiveDialogFooter({ function ResponsiveDialogFooter({
@@ -175,10 +187,13 @@ function ResponsiveDialogFooter({
return ( return (
<div <div
data-slot="responsive-dialog-footer" data-slot="responsive-dialog-footer"
className={cn("mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2", className)} className={cn(
"mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
className,
)}
{...props} {...props}
/> />
) );
} }
function ResponsiveDialogTitle({ function ResponsiveDialogTitle({
@@ -191,7 +206,7 @@ function ResponsiveDialogTitle({
className={cn("text-base font-medium text-foreground", className)} className={cn("text-base font-medium text-foreground", className)}
{...props} {...props}
/> />
) );
} }
function ResponsiveDialogDescription({ function ResponsiveDialogDescription({
@@ -204,22 +219,24 @@ function ResponsiveDialogDescription({
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function ResponsiveDialogClose({ function ResponsiveDialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} /> return (
<DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
);
} }
export { export {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogTrigger,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogFooter,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogClose, ResponsiveDialogClose,
} ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
};

View File

@@ -1,13 +1,12 @@
import * as React from "react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
@@ -20,13 +19,13 @@ function SelectGroup({
className={cn("scroll-my-1 p-1", className)} className={cn("scroll-my-1 p-1", className)}
{...props} {...props}
/> />
) );
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@@ -35,7 +34,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -43,7 +42,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -52,7 +51,7 @@ function SelectTrigger({
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" /> <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -67,7 +66,12 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
data-align-trigger={position === "item-aligned"} data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position} position={position}
align={align} align={align}
{...props} {...props}
@@ -77,7 +81,7 @@ function SelectContent({
data-position={position} data-position={position}
className={cn( className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && "" position === "popper" && "",
)} )}
> >
{children} {children}
@@ -85,7 +89,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -98,7 +102,7 @@ function SelectLabel({
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)} className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -111,7 +115,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@@ -122,7 +126,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -135,7 +139,7 @@ function SelectSeparator({
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -147,14 +151,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon <ChevronUpIcon />
/>
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -166,14 +169,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4", "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon <ChevronDownIcon />
/>
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -187,4 +189,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View File

@@ -1,7 +1,7 @@
import * as React from "react" import { Separator as SeparatorPrimitive } from "radix-ui";
import { Separator as SeparatorPrimitive } from "radix-ui" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
@@ -16,11 +16,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@@ -1,32 +1,31 @@
"use client" "use client";
import * as React from "react" import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui" import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -38,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -52,8 +51,8 @@ function SheetContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -63,7 +62,7 @@ function SheetContent({
data-side={side} data-side={side}
className={cn( className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className className,
)} )}
{...props} {...props}
> >
@@ -75,15 +74,14 @@ function SheetContent({
className="absolute top-3 right-3" className="absolute top-3 right-3"
size="icon-sm" size="icon-sm"
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -93,7 +91,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-0.5 p-4", className)} className={cn("flex flex-col gap-0.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -103,7 +101,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -113,13 +111,10 @@ function SheetTitle({
return ( return (
<SheetPrimitive.Title <SheetPrimitive.Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn( className={cn("text-base font-medium text-foreground", className)}
"text-base font-medium text-foreground",
className
)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -132,16 +127,16 @@ function SheetDescription({
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
Sheet, Sheet,
SheetTrigger,
SheetClose, SheetClose,
SheetContent, SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription, SheetDescription,
} SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
};

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Tooltip as TooltipPrimitive } from "radix-ui" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,19 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -43,14 +43,14 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "z-50 max-w-sm rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };

View File

@@ -0,0 +1,407 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { subDays } from "date-fns";
import { ArrowLeft } from "lucide-react";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { DatePicker } from "@/components/ui/date-picker";
import { useDailyQuotes, useHistoricalMinMax, useQuotes } from "@/lib/queries";
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
const CURRENCY_LABELS: Record<string, string> = {
BELO: "BELO",
BLUE: "Dólar Blue",
BNA: "Dólar Oficial",
};
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const dateFormatter = new Intl.DateTimeFormat("es-AR", {
day: "numeric",
month: "long",
year: "numeric",
});
function formatShortDate(iso: string): string {
const datePart = iso.split(" ")[0];
const [y, m, d] = datePart.split("-").map(Number);
return dateFormatter.format(new Date(y, m - 1, d));
}
export function AnalysisPage({ currency }: { currency: string }) {
const navigate = useNavigate();
const today = new Date();
const defaultStart = subDays(today, 30);
const [startDate, setStartDate] = useState<Date>(defaultStart);
const [endDate, setEndDate] = useState<Date>(today);
const startDateStr = formatDate(startDate);
const endDateStr = formatDate(endDate);
const { data: historical, isLoading: historicalLoading } =
useHistoricalMinMax(currency);
const { data: quotes } = useQuotes();
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
currency,
startDateStr,
endDateStr,
);
const yesterday = subDays(today, 1);
const yesterdayStr = formatDate(yesterday);
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const firstOfMonthStr = formatDate(firstOfMonth);
const { data: yesterdayQuote } = useDailyQuotes(
currency,
yesterdayStr,
yesterdayStr,
);
const { data: firstDayQuote } = useDailyQuotes(
currency,
firstOfMonthStr,
firstOfMonthStr,
);
const chartData = useMemo(() => {
if (!daily) return [];
return daily.map((d) => ({
date: formatShortDate(d.date),
buy: d.buy,
}));
}, [daily]);
const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => {
if (chartData.length === 0)
return {
yDomain: [0, 0] as [number, number],
minBuyValue: 0,
maxBuyValue: 0,
avgBuyValue: 0,
};
let min = Infinity;
let max = -Infinity;
let sum = 0;
for (const d of chartData) {
if (d.buy < min) min = d.buy;
if (d.buy > max) max = d.buy;
sum += d.buy;
}
const range = max - min || 1;
return {
yDomain: [min - range * 0.1, max + range * 0.1] as [number, number],
minBuyValue: min,
maxBuyValue: max,
avgBuyValue: sum / chartData.length,
};
}, [chartData]);
const currentQuote = quotes?.find((q) => q.type === currency);
const currentBuy = currentQuote ? Number(currentQuote.buy) : 0;
const yesterdayBuy = yesterdayQuote?.[0]?.buy ?? null;
const prevDayDiff = yesterdayBuy !== null ? currentBuy - yesterdayBuy : null;
const prevDayPct =
prevDayDiff !== null && yesterdayBuy !== null && yesterdayBuy !== 0
? (prevDayDiff / yesterdayBuy) * 100
: null;
const firstMonthBuy = firstDayQuote?.[0]?.buy ?? null;
const monthDiff = firstMonthBuy !== null ? currentBuy - firstMonthBuy : null;
const monthPct =
monthDiff !== null && firstMonthBuy !== null && firstMonthBuy !== 0
? (monthDiff / firstMonthBuy) * 100
: null;
const backTo = currency === "BELO" ? "/quotes/belo" : "/quotes";
const backLabel = currency === "BELO" ? "BELO" : "Cotizaciones";
function VariationValue({
value,
pct,
}: {
value: number | null;
pct: number | null;
}) {
if (value === null || pct === null)
return <p className="text-sm text-muted-foreground">Sin datos</p>;
const isPositive = value >= 0;
const color = isPositive ? "text-emerald-500" : "text-red-500";
const sign = isPositive ? "+" : "";
return (
<p className={`text-lg font-bold tabular-nums ${color}`}>
{sign}${priceFormatter.format(Math.abs(value))}{" "}
<span className="text-sm font-normal">
({sign}
{pct.toFixed(2)}%)
</span>
</p>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
to={backTo}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
{backLabel}
</Link>
<h1 className="text-2xl font-semibold">Análisis</h1>
</div>
<div className="flex items-center gap-1 rounded-lg border p-0.5">
{CURRENCIES.map((c) => (
<button
key={c}
type="button"
onClick={() =>
navigate({ to: "/quotes/analysis/$currency", params: { currency: c } })
}
className={`px-3 py-1 text-xs rounded-md transition-colors cursor-pointer ${
currency === c
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{CURRENCY_LABELS[c]}
</button>
))}
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Cotización actual (compra)
</p>
<p className="text-2xl font-bold">
${priceFormatter.format(currentBuy)}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Mínimo histórico (compra)
</p>
{historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
<p className="text-2xl font-bold">
${priceFormatter.format(historical?.minBuy ?? 0)}
</p>
{historical?.minBuyDate && (
<p className="text-xs text-muted-foreground mt-0.5">
el {formatShortDate(historical.minBuyDate)}
</p>
)}
</>
)}
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Máximo histórico (compra)
</p>
{historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
<p className="text-2xl font-bold">
${priceFormatter.format(historical?.maxBuy ?? 0)}
</p>
{historical?.maxBuyDate && (
<p className="text-xs text-muted-foreground mt-0.5">
el {formatShortDate(historical.maxBuyDate)}
</p>
)}
</>
)}
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">Vs. día anterior</p>
<VariationValue value={prevDayDiff} pct={prevDayPct} />
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">
Vs. primer día del mes
</p>
<VariationValue value={monthDiff} pct={monthPct} />
</div>
</div>
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Evolución del precio de compra
</h2>
<div className="flex items-center gap-4 mb-4">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Desde</span>
<DatePicker
value={startDate}
onChange={(d) => d && setStartDate(d)}
placeholder="Fecha inicio"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Hasta</span>
<DatePicker
value={endDate}
onChange={(d) => d && setEndDate(d)}
placeholder="Fecha fin"
/>
</div>
</div>
{dailyLoading ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Cargando...
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Sin datos para este período
</div>
) : (
<>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="date"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-muted-foreground"
tickFormatter={(v: number) =>
`$${priceFormatter.format(v)}`
}
width={80}
domain={yDomain}
/>
<Tooltip
formatter={(value: unknown) => [
`$${priceFormatter.format(Number(value))}`,
"Compra",
]}
labelFormatter={(label: unknown) => `Fecha: ${label}`}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
fontSize: 13,
}}
/>
<ReferenceLine
y={maxBuyValue}
stroke="var(--chart-1)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Máx: $${priceFormatter.format(maxBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-1)",
}}
/>
<ReferenceLine
y={minBuyValue}
stroke="var(--chart-2)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Mín: $${priceFormatter.format(minBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-2)",
}}
/>
<ReferenceLine
y={avgBuyValue}
stroke="var(--chart-3)"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: `Prom: $${priceFormatter.format(avgBuyValue)}`,
position: "right",
fontSize: 11,
fill: "var(--chart-3)",
}}
/>
<Line
type="linear"
dataKey="buy"
name="Compra"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Compra</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Máx</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">Mín</span>
</div>
<div className="flex items-center gap-1.5">
<div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-3)" }}
/>
<span className="text-xs text-muted-foreground">Prom</span>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,25 +1,32 @@
import { Link } from "@tanstack/react-router";
import { BarChart3, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
LineChart, CartesianGrid,
Line, Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts"; } from "recharts";
import { import {
Gauge, Gauge,
GaugeIndicator, GaugeIndicator,
GaugeTrack,
GaugeRange, GaugeRange,
GaugeTrack,
GaugeValueText, GaugeValueText,
} from "@/components/ui/gauge"; } from "@/components/ui/gauge";
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries"; import {
useDailyMinMax,
useDailyQuotes,
useFetchQuotes,
useQuoteHistory,
useQuotes,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time"; import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -39,11 +46,8 @@ function formatTime(iso: string): string {
} }
function formatShortDate(iso: string): string { function formatShortDate(iso: string): string {
const d = new Date(iso); const [, m, d] = iso.split("-");
return d.toLocaleDateString("es-AR", { return `${d}/${m}`;
day: "2-digit",
month: "2-digit",
});
} }
function getGaugeColor(percentage: number | null): string { function getGaugeColor(percentage: number | null): string {
@@ -104,15 +108,13 @@ export function BeloPage() {
const now = new Date(); const now = new Date();
const todayStr = formatDate(now); const todayStr = formatDate(now);
const firstOfMonth = formatDate(new Date(now.getFullYear(), now.getMonth(), 1)); const firstOfMonth = formatDate(
new Date(now.getFullYear(), now.getMonth(), 1),
);
const { data: quotes, isLoading: quotesLoading } = useQuotes(); const { data: quotes, isLoading: quotesLoading } = useQuotes();
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes(); const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax( const { data: dailyMinMax } = useDailyMinMax("BELO", todayStr, todayStr);
"BELO",
todayStr,
todayStr,
);
const { data: monthlyMinMax } = useDailyMinMax( const { data: monthlyMinMax } = useDailyMinMax(
"BELO", "BELO",
firstOfMonth, firstOfMonth,
@@ -139,7 +141,8 @@ export function BeloPage() {
const minSell = maxData ? Number(maxData.minSell) : 0; const minSell = maxData ? Number(maxData.minSell) : 0;
const maxSell = maxData ? Number(maxData.maxSell) : 0; const maxSell = maxData ? Number(maxData.maxSell) : 0;
const gaugeValue = currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null; const gaugeValue =
currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
const gaugePercentage = useMemo(() => { const gaugePercentage = useMemo(() => {
if (gaugeValue !== null && maxBuy > minBuy) { if (gaugeValue !== null && maxBuy > minBuy) {
@@ -220,42 +223,40 @@ export function BeloPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold">BELO</h1> <h1 className="text-2xl font-semibold">BELO</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
{belo && ( {belo && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Últ. actualización: <RelativeTime date={belo.timeStamp} /> Últ. actualización: <RelativeTime date={belo.timeStamp} />
</span> </span>
)} )}
<Link
to="/quotes/analysis/$currency"
params={{ currency: "BELO" }}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<BarChart3 className="h-4 w-4" />
Análisis
</Link>
<button <button
type="button" type="button"
disabled={isFetching} disabled={isFetching}
onClick={() => doFetchQuotes()} onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} /> <RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
Actualizar Actualizar
</button> </button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-5"> <div className="grid gap-4 md:grid-cols-5">
<StatCard <StatCard label="Precio actual" buy={currentBuy} sell={currentSell} />
label="Precio actual" <StatCard label="Mínimo del día" buy={minBuy} sell={minSell} />
buy={currentBuy} <StatCard label="Máximo del día" buy={maxBuy} sell={maxSell} />
sell={currentSell}
/>
<StatCard
label="Mínimo del día"
buy={minBuy}
sell={minSell}
/>
<StatCard
label="Máximo del día"
buy={maxBuy}
sell={maxSell}
/>
<StatCard <StatCard
label="Máximo del mes" label="Máximo del mes"
buy={monthlyMax?.maxBuy ?? 0} buy={monthlyMax?.maxBuy ?? 0}
@@ -274,7 +275,8 @@ export function BeloPage() {
startAngle={0} startAngle={0}
endAngle={360} endAngle={360}
getValueText={(v, m, mx) => { getValueText={(v, m, mx) => {
const pct = mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100); const pct =
mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
return `${pct}%`; return `${pct}%`;
}} }}
> >
@@ -282,7 +284,12 @@ export function BeloPage() {
<GaugeTrack /> <GaugeTrack />
<GaugeRange className={getGaugeColor(gaugePercentage)} /> <GaugeRange className={getGaugeColor(gaugePercentage)} />
</GaugeIndicator> </GaugeIndicator>
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} /> <GaugeValueText
className={cn(
getGaugeColor(gaugePercentage),
"text-sm font-semibold",
)}
/>
</Gauge> </Gauge>
</div> </div>
</div> </div>
@@ -328,7 +335,10 @@ export function BeloPage() {
<div className="h-64"> <div className="h-64">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}> <LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" /> <CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis <XAxis
dataKey="time" dataKey="time"
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
@@ -343,11 +353,11 @@ export function BeloPage() {
domain={yDomain} domain={yDomain}
/> />
<Tooltip <Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any // biome-ignore lint/suspicious/noExplicitAny: recharts types
formatter={(value: any) => [ formatter={(value: any) => [
`$${priceFormatter.format(Number(value))}`, `$${priceFormatter.format(Number(value))}`,
]} ]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // biome-ignore lint/suspicious/noExplicitAny: recharts types
labelFormatter={(label: any) => labelFormatter={(label: any) =>
period === "day" ? `Hora: ${label}` : `Fecha: ${label}` period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
} }
@@ -383,7 +393,7 @@ export function BeloPage() {
}} }}
/> />
<Line <Line
type="monotone" type="linear"
dataKey="buy" dataKey="buy"
name="Compra" name="Compra"
stroke="var(--chart-1)" stroke="var(--chart-1)"
@@ -393,7 +403,7 @@ export function BeloPage() {
isAnimationActive={false} isAnimationActive={false}
/> />
<Line <Line
type="monotone" type="linear"
dataKey="sell" dataKey="sell"
name="Venta" name="Venta"
stroke="var(--chart-2)" stroke="var(--chart-2)"
@@ -409,11 +419,17 @@ export function BeloPage() {
<div className="flex items-center justify-center gap-4 mt-3"> <div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} /> <span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Compra</span> <span className="text-xs text-muted-foreground">Compra</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} /> <span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">Venta</span> <span className="text-xs text-muted-foreground">Venta</span>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,38 @@
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { RelativeTime } from "@/lib/time";
import type { Quote } from "@/lib/api";
import { useQuotes, useFetchQuotes, useMonthlyPayedTotal } from "@/lib/queries";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { Quote } from "@/lib/api";
import {
useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals,
useQuotes,
useWallets,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; title: string; to?: string; variant?: "default" | "minimal" }) { function QuoteCard({
quote,
title,
to,
variant,
}: {
quote: Quote | undefined;
title: string;
to?: string;
variant?: "default" | "minimal";
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const diff = quote ? Number(quote.difference) : 0; const diff = quote ? Number(quote.difference) : 0;
const pct = quote ? Number(quote.percentage) : 0; const pct = quote ? Number(quote.percentage) : 0;
@@ -23,10 +40,13 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
const isDown = diff < 0; const isDown = diff < 0;
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: conditional button
<div <div
className="rounded-lg border p-4 cursor-pointer" className="rounded-lg border p-4 cursor-pointer"
onClick={() => to && navigate({ to })} onClick={() => to && navigate({ to })}
onKeyDown={(e) => { if (e.key === "Enter" && to) navigate({ to }); }} onKeyDown={(e) => {
if (e.key === "Enter" && to) navigate({ to });
}}
role={to ? "button" : undefined} role={to ? "button" : undefined}
tabIndex={to ? 0 : undefined} tabIndex={to ? 0 : undefined}
> >
@@ -69,7 +89,9 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger className="cursor-default"> <TooltipTrigger className="cursor-default">
<p className="text-2xl font-bold tabular-nums text-left">${priceFormatter.format(Number(quote.buy))}</p> <p className="text-2xl font-bold tabular-nums text-left">
${priceFormatter.format(Number(quote.buy))}
</p>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
<p>Compra: ${priceFormatter.format(Number(quote.buy))}</p> <p>Compra: ${priceFormatter.format(Number(quote.buy))}</p>
@@ -80,10 +102,16 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm"> <p className="text-sm">
Compra: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.buy))}</span> Compra:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(quote.buy))}
</span>
</p> </p>
<p className="text-sm"> <p className="text-sm">
Venta: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.sell))}</span> Venta:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(quote.sell))}
</span>
</p> </p>
</div> </div>
) )
@@ -100,12 +128,27 @@ export function DashboardPage() {
const belo = quotes?.find((q) => q.type === "BELO"); const belo = quotes?.find((q) => q.type === "BELO");
const blue = quotes?.find((q) => q.type === "BLUE"); const blue = quotes?.find((q) => q.type === "BLUE");
const bna = quotes?.find((q) => q.type === "BNA");
const now = new Date(); const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1); const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(),
now.getMonth() + 1,
);
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">
@@ -116,22 +159,64 @@ export function DashboardPage() {
onClick={() => doFetchQuotes()} onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} /> <RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
Actualizar Actualizar
</button> </button>
</div> </div>
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4 lg:grid-cols-5">
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total ingresos</p> <p className="text-sm text-muted-foreground">Pagos pendientes</p>
<p className="text-2xl font-bold">$0.00</p> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyTotals?.pending ?? 0)}
</p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p> <p className="text-sm text-muted-foreground">Gastos del mes</p>
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
</p>
</div> </div>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" /> <QuoteCard
<QuoteCard quote={blue} title="BLUE" variant="minimal" /> quote={belo}
title="BELO"
to="/quotes/analysis/BELO"
variant="minimal"
/>
<QuoteCard
quote={blue}
title="BLUE"
to="/quotes/analysis/BLUE"
variant="minimal"
/>
<QuoteCard
quote={bna}
title="Dolar Oficial"
to="/quotes/analysis/BNA"
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 />
</div> </div>
); );
} }

View File

@@ -0,0 +1,230 @@
import { useNavigate } from "@tanstack/react-router";
import { CalendarClock, ExternalLink } from "lucide-react";
import { useMemo } from "react";
import type { PendingUpcomingExpense } from "@/lib/api";
import { usePendingUpcomingExpenses } from "@/lib/queries";
type GroupedExpense = {
periodicExpenseId: number;
description: string;
totalAmount: number;
earliestDueDate: string;
dueType: "overdue" | "upcoming";
count: number;
};
function groupExpenses(expenses: PendingUpcomingExpense[]): GroupedExpense[] {
const map = new Map<number, GroupedExpense>();
for (const e of expenses) {
if (e.periodicExpenseId === null) continue;
const key = e.periodicExpenseId;
const existing = map.get(key);
if (existing) {
existing.totalAmount += Number(e.amount);
existing.count += 1;
if (e.dueDate < existing.earliestDueDate) {
existing.earliestDueDate = e.dueDate;
}
if (e.dueType === "overdue") {
existing.dueType = "overdue";
}
} else {
map.set(key, {
periodicExpenseId: key,
description: e.periodicExpense?.description ?? e.description,
totalAmount: Number(e.amount),
earliestDueDate: e.dueDate,
dueType: e.dueType,
count: 1,
});
}
}
return Array.from(map.values()).sort((a, b) => {
if (a.dueType !== b.dueType) return a.dueType === "overdue" ? -1 : 1;
return a.earliestDueDate.localeCompare(b.earliestDueDate);
});
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatAmount(value: number) {
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
}
function DueTypeBadge({ dueType }: { dueType: "overdue" | "upcoming" }) {
return (
<span
className={
dueType === "overdue"
? "inline-flex h-5 shrink-0 items-center rounded-md bg-red-100 px-1.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
: "inline-flex h-5 shrink-0 items-center rounded-md bg-amber-100 px-1.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
}
>
{dueType === "overdue" ? "Vencido" : "Próximo"}
</span>
);
}
export function DashboardExpensesTable() {
const { data: expenses, isLoading } = usePendingUpcomingExpenses();
const navigate = useNavigate();
const groups = useMemo(() => {
if (!expenses) return [];
return groupExpenses(expenses);
}, [expenses]);
function goToExpenses(periodicExpenseId: number) {
navigate({ to: "/expenses", search: { periodicExpenseId } });
}
const overdueCount = groups.filter((g) => g.dueType === "overdue").length;
const upcomingCount = groups.filter((g) => g.dueType === "upcoming").length;
const grandTotal = groups.reduce((sum, g) => sum + g.totalAmount, 0);
const hasData = groups.length > 0;
return (
<div className="rounded-lg border">
<div className="flex items-center gap-2 border-b px-4 py-3">
<CalendarClock className="size-4 text-muted-foreground" />
<h2 className="text-sm font-medium">Gastos por vencer</h2>
{isLoading && (
<span className="ml-auto text-xs text-muted-foreground">
Cargando...
</span>
)}
{!isLoading && hasData && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{overdueCount} vencido{overdueCount !== 1 ? "s" : ""}
{upcomingCount > 0 &&
` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`}
</span>
)}
</div>
{!isLoading && !hasData && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No hay gastos pendientes.
</div>
)}
{isLoading && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Cargando...
</div>
)}
<div className="space-y-1 p-1 sm:hidden">
{groups.map((group) => (
<div
key={group.periodicExpenseId}
className="rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<button
type="button"
onClick={() => goToExpenses(group.periodicExpenseId)}
className="truncate font-medium text-left hover:underline cursor-pointer inline-flex items-center gap-1"
>
{group.description}
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</button>
<p className="mt-1 text-xs text-muted-foreground">
Vence {formatDate(group.earliestDueDate)}
{group.count > 1 && ` (${group.count} gastos)`}
</p>
</div>
<DueTypeBadge dueType={group.dueType} />
</div>
<p className="mt-3 text-lg font-semibold tabular-nums">
{formatAmount(group.totalAmount)}
</p>
</div>
))}
{hasData && (
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-3 py-2.5 text-sm font-medium">
<span>Total</span>
<span className="tabular-nums">{formatAmount(grandTotal)}</span>
</div>
)}
</div>
<div className="hidden overflow-x-auto sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Descripción
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Monto
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Vencimiento
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Cantidad
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
Estado
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => (
<tr
key={group.periodicExpenseId}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2.5">
<button
type="button"
onClick={() => goToExpenses(group.periodicExpenseId)}
className="font-medium text-left hover:underline cursor-pointer inline-flex items-center gap-1"
>
{group.description}
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</button>
</td>
<td className="px-3 py-2.5 tabular-nums">
{formatAmount(group.totalAmount)}
</td>
<td className="px-3 py-2.5 text-muted-foreground">
{formatDate(group.earliestDueDate)}
</td>
<td className="px-3 py-2.5 text-muted-foreground tabular-nums">
{group.count > 1 ? `${group.count} gastos` : "1 gasto"}
</td>
<td className="px-3 py-2.5">
<DueTypeBadge dueType={group.dueType} />
</td>
</tr>
))}
{hasData && (
<tr className="border-t bg-muted/50 font-medium">
<td className="px-3 py-2.5">Total</td>
<td className="px-3 py-2.5 tabular-nums">
{formatAmount(grandTotal)}
</td>
<td colSpan={3} />
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { ExpensesProvider } from "./ExpensesProvider"; import { Route } from "@/routes/_authenticated/expenses";
import { ExpensesTabContent } from "./components/ExpensesTabContent"; import { ExpensesTabContent } from "./components/ExpensesTabContent";
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent"; import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
import { ExpensesProvider } from "./ExpensesProvider";
const tabs = [ const tabs = [
{ id: "expenses", label: "Gastos" }, { id: "expenses", label: "Gastos" },
@@ -15,9 +16,9 @@ function ExpensesPageInner() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1> <h1 className="text-2xl font-semibold text-balance">Gastos</h1>
<div className="flex gap-1 border-b"> <div className="scrollbar-none flex gap-1 overflow-x-auto border-b">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
@@ -25,8 +26,8 @@ function ExpensesPageInner() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={ className={
activeTab === tab.id activeTab === tab.id
? "-mb-px border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground" ? "-mb-px shrink-0 border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
: "px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground" : "shrink-0 px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
} }
> >
{tab.label} {tab.label}
@@ -41,8 +42,9 @@ function ExpensesPageInner() {
} }
export function ExpensesPage() { export function ExpensesPage() {
const { periodicExpenseId } = Route.useSearch();
return ( return (
<ExpensesProvider> <ExpensesProvider initialPeriodicExpenseId={periodicExpenseId}>
<ExpensesPageInner /> <ExpensesPageInner />
</ExpensesProvider> </ExpensesProvider>
); );

View File

@@ -1,30 +1,40 @@
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { import {
usePeriodicExpenses, createContext,
useCreatePeriodicExpense, type ReactNode,
useUpdatePeriodicExpense, useCallback,
useDeletePeriodicExpense, useContext,
useGenerateMonthlyExpense, useEffect,
useExpenses, useRef,
useCreateNonPeriodicExpense, useState,
usePayExpense, } from "react";
useMonthlyTotals,
useTotalPending,
useImportPeriodicExpenses,
useImportExpenses,
} from "@/lib/queries";
import type { import type {
PeriodicExpense,
Expense,
CreatePeriodicExpenseInput,
CreateNonPeriodicExpenseInput, CreateNonPeriodicExpenseInput,
PayExpenseInput, CreatePeriodicExpenseInput,
PaginatedResponse, Expense,
MonthlyTotals,
MonthlyExpensesTotal,
ImportResult,
ImportExpensesResult, ImportExpensesResult,
ImportResult,
MonthlyExpensesTotal,
MonthlyTotals,
PaginatedResponse,
PayExpenseInput,
PeriodicExpense,
UpdateExpenseInput,
} from "@/lib/api"; } from "@/lib/api";
import {
useCreateNonPeriodicExpense,
useCreatePeriodicExpense,
useDeletePeriodicExpense,
useExpenses,
useGenerateMonthlyExpense,
useImportExpenses,
useImportPeriodicExpenses,
useMonthlyTotals,
usePayExpense,
usePeriodicExpenses,
useTotalPending,
useUpdateExpense,
useUpdatePeriodicExpense,
} from "@/lib/queries";
interface ExpensesContextValue { interface ExpensesContextValue {
periodicExpenses: PeriodicExpense[]; periodicExpenses: PeriodicExpense[];
@@ -47,23 +57,39 @@ interface ExpensesContextValue {
totalPending: MonthlyExpensesTotal | undefined; totalPending: MonthlyExpensesTotal | undefined;
isLoadingTotalPending: boolean; isLoadingTotalPending: boolean;
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>; createPeriodicExpense: (
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>; data: CreatePeriodicExpenseInput,
) => Promise<PeriodicExpense>;
updatePeriodicExpense: (
id: number,
data: CreatePeriodicExpenseInput,
) => Promise<void>;
deletePeriodicExpense: (id: number) => Promise<void>; deletePeriodicExpense: (id: number) => Promise<void>;
generateMonthlyExpense: (id: number) => Promise<Expense>; generateMonthlyExpense: (id: number) => Promise<Expense>;
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>; createNonPeriodicExpense: (
data: CreateNonPeriodicExpenseInput,
) => Promise<void>;
payExpense: (id: number, data: PayExpenseInput) => Promise<void>; payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
updateExpense: (id: number, data: UpdateExpenseInput) => Promise<void>;
importPeriodicExpenses: (file: File) => Promise<ImportResult>; importPeriodicExpenses: (file: File) => Promise<ImportResult>;
importExpenses: (file: File) => Promise<ImportExpensesResult>; importExpenses: (file: File) => Promise<ImportExpensesResult>;
} }
const ExpensesContext = createContext<ExpensesContextValue | null>(null); const ExpensesContext = createContext<ExpensesContextValue | null>(null);
export function ExpensesProvider({ children }: { children: ReactNode }) { export function ExpensesProvider({
children,
initialPeriodicExpenseId,
}: {
children: ReactNode;
initialPeriodicExpenseId?: number;
}) {
const [statusFilter, setStatusFilter] = useState("PENDING"); const [statusFilter, setStatusFilter] = useState("PENDING");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<number | undefined>(undefined); const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<
number | undefined
>(initialPeriodicExpenseId);
const [searchInput, setSearchInput] = useState(""); const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -81,15 +107,28 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [periodicExpenseFilter, searchQuery]); }, []);
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses(); const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } =
const { data: expenses = { data: [], total: 0, page: 1, pageSize: 10 }, isLoading: isLoadingExpenses } = usePeriodicExpenses();
useExpenses(statusFilter, page, pageSize, periodicExpenseFilter, searchQuery || undefined); const {
data: expenses = { data: [], total: 0, page: 1, pageSize: 10 },
isLoading: isLoadingExpenses,
} = useExpenses(
statusFilter,
page,
pageSize,
periodicExpenseFilter,
searchQuery || undefined,
);
const now = new Date(); const now = new Date();
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1); const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(
const { data: totalPending, isLoading: isLoadingTotalPending } = useTotalPending(); now.getFullYear(),
now.getMonth() + 1,
);
const { data: totalPending, isLoading: isLoadingTotalPending } =
useTotalPending();
const createPeriodicMutation = useCreatePeriodicExpense(); const createPeriodicMutation = useCreatePeriodicExpense();
const updatePeriodicMutation = useUpdatePeriodicExpense(); const updatePeriodicMutation = useUpdatePeriodicExpense();
@@ -97,6 +136,7 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
const generateMonthlyMutation = useGenerateMonthlyExpense(); const generateMonthlyMutation = useGenerateMonthlyExpense();
const createExpenseMutation = useCreateNonPeriodicExpense(); const createExpenseMutation = useCreateNonPeriodicExpense();
const payExpenseMutation = usePayExpense(); const payExpenseMutation = usePayExpense();
const updateExpenseMutation = useUpdateExpense();
const importPeriodicMutation = useImportPeriodicExpenses(); const importPeriodicMutation = useImportPeriodicExpenses();
const importExpensesMutation = useImportExpenses(); const importExpensesMutation = useImportExpenses();
@@ -156,6 +196,13 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
[payExpenseMutation], [payExpenseMutation],
); );
const updateExpenseFn = useCallback(
async (id: number, data: UpdateExpenseInput) => {
await updateExpenseMutation.mutateAsync({ id, data });
},
[updateExpenseMutation],
);
return ( return (
<ExpensesContext <ExpensesContext
value={{ value={{
@@ -182,6 +229,7 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
generateMonthlyExpense: generateMonthlyExpenseFn, generateMonthlyExpense: generateMonthlyExpenseFn,
createNonPeriodicExpense: createNonPeriodicExpenseFn, createNonPeriodicExpense: createNonPeriodicExpenseFn,
payExpense: payExpenseFn, payExpense: payExpenseFn,
updateExpense: updateExpenseFn,
importPeriodicExpenses: importPeriodicExpensesFn, importPeriodicExpenses: importPeriodicExpensesFn,
importExpenses: importExpensesFn, importExpenses: importExpensesFn,
}} }}
@@ -193,6 +241,7 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
export function useExpensesContext() { export function useExpensesContext() {
const ctx = useContext(ExpensesContext); const ctx = useContext(ExpensesContext);
if (!ctx) throw new Error("useExpensesContext must be used within ExpensesProvider"); if (!ctx)
throw new Error("useExpensesContext must be used within ExpensesProvider");
return ctx; return ctx;
} }

View File

@@ -0,0 +1,309 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useRef } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
const editSchema = z.object({
amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }),
amountPayed: z.number().positive("El monto pagado debe ser mayor a 0").optional(),
paymentDate: z.date().optional(),
markAsPending: z.boolean(),
});
type EditFormValues = z.infer<typeof editSchema>;
interface EditExpenseDialogProps {
expense: Expense | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditExpenseDialog({
expense,
open,
onOpenChange,
}: EditExpenseDialogProps) {
const { updateExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const isPayed = expense?.status === "PAYED";
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: {
amount: 0,
dueDate: undefined,
amountPayed: undefined,
paymentDate: undefined,
markAsPending: false,
},
});
const { ref: amountRef, ...amountField } = register("amount", {
setValueAs: (value) => Number(value),
});
const markAsPending = useWatch({ control, name: "markAsPending" });
useEffect(() => {
if (expense) {
reset({
amount: Number(expense.amount),
dueDate: new Date(expense.dueDate),
amountPayed: expense.amountPayed
? Number(expense.amountPayed)
: undefined,
paymentDate: expense.paymentDate
? new Date(expense.paymentDate)
: undefined,
markAsPending: false,
});
}
}, [expense, reset]);
useEffect(() => {
if (!open || !expense) return;
const frame = requestAnimationFrame(() => {
amountInputRef.current?.focus();
amountInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}, [open, expense]);
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: EditFormValues) {
if (!expense) return;
if (isPayed && data.markAsPending) {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
status: "PENDING",
});
} else if (isPayed) {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
amountPayed: data.amountPayed!,
paymentDate: data.paymentDate!.toISOString(),
});
} else {
await updateExpense(expense.id, {
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
}
handleClose(false);
}
if (!expense) return null;
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Editar gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span>
</div>
<form
id="edit-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="amount" className="text-sm font-medium">
Monto
</label>
<input
id="amount"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...amountField}
ref={(node) => {
amountRef(node);
amountInputRef.current = node;
}}
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">
<span className="text-sm font-medium">Fecha de vencimiento</span>
<Controller
control={control}
name="dueDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)}
</div>
{isPayed && (
<>
<hr className="border-border" />
<div className="flex flex-col gap-3">
<span className="text-sm font-medium">
Información del pago
</span>
{!markAsPending && (
<>
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...register("amountPayed", {
setValueAs: (value) => Number(value),
})}
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.amountPayed && (
<span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
Fecha de pago
</span>
<Controller
control={control}
name="paymentDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)}
</div>
</>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Controller
control={control}
name="markAsPending"
render={({ field }) => (
<button
type="button"
role="checkbox"
aria-checked={field.value}
onClick={() => field.onChange(!field.value)}
className={
field.value
? "flex size-4 shrink-0 items-center justify-center rounded-[4px] bg-primary text-primary-foreground ring-offset-background transition-colors"
: "flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-background ring-offset-background transition-colors hover:border-ring"
}
>
{field.value && (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 5.5L4 7.5L8 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
)}
/>
<span>Marcar como pendiente</span>
</label>
{markAsPending && (
<p className="text-xs text-muted-foreground">
Al marcar como pendiente, se borrarán el monto pagado y la
fecha de pago.
</p>
)}
</div>
</>
)}
</form>
<ResponsiveDialogFooter>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="submit"
form="edit-expense-form"
disabled={isSubmitting}
>
Guardar cambios
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -1,17 +1,14 @@
import { useState, useRef } from "react"; import { Plus, Search, Upload, X } from "lucide-react";
import { useExpensesContext } from "../ExpensesProvider"; import { useRef, useState } from "react";
import { ExpensesTable } from "./ExpensesTable"; import { Button } from "@/components/ui/button";
import { PayExpenseDialog } from "./PayExpenseDialog"; import { Input } from "@/components/ui/input";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,8 +16,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Plus, Upload, Search, X } from "lucide-react";
import type { Expense, ImportExpensesResult } from "@/lib/api"; import type { Expense, ImportExpensesResult } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { EditExpenseDialog } from "./EditExpenseDialog";
import { ExpensesTable } from "./ExpensesTable";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { PayExpenseDialog } from "./PayExpenseDialog";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -33,9 +34,7 @@ export function ExpensesTabContent() {
isLoadingExpenses, isLoadingExpenses,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
page,
setPage, setPage,
pageSize,
monthlyTotals, monthlyTotals,
isLoadingTotals, isLoadingTotals,
totalPending, totalPending,
@@ -48,12 +47,19 @@ export function ExpensesTabContent() {
importExpenses, importExpenses,
} = useExpensesContext(); } = useExpensesContext();
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null); const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
null,
);
const [editDialogExpense, setEditDialogExpense] = useState<Expense | null>(
null,
);
const [newExpenseOpen, setNewExpenseOpen] = useState(false); const [newExpenseOpen, setNewExpenseOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportExpensesResult | null>(null); const [importResult, setImportResult] = useState<ImportExpensesResult | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const filters = [ const filters = [
@@ -64,21 +70,25 @@ export function ExpensesTabContent() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap"> <div className="space-y-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between sm:gap-2 sm:space-y-0">
<div className="flex gap-1"> <div className="scrollbar-none flex gap-1 overflow-x-auto pb-1 sm:pb-0">
{filters.map((f) => ( {filters.map((f) => (
<Button <Button
key={f.value} key={f.value}
variant={statusFilter === f.value ? "default" : "outline"} variant={statusFilter === f.value ? "default" : "outline"}
size="xs" size="xs"
onClick={() => { setStatusFilter(f.value); setPage(1); }} className="shrink-0"
onClick={() => {
setStatusFilter(f.value);
setPage(1);
}}
> >
{f.label} {f.label}
</Button> </Button>
))} ))}
</div> </div>
<div className="flex items-center gap-2"> <div className="grid gap-2 sm:flex sm:items-center">
<div className="relative w-48"> <div className="relative min-w-0 sm:w-48">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Buscar por descripción..." placeholder="Buscar por descripción..."
@@ -97,13 +107,17 @@ export function ExpensesTabContent() {
)} )}
</div> </div>
<Select <Select
value={periodicExpenseFilter !== undefined ? String(periodicExpenseFilter) : "all"} value={
periodicExpenseFilter !== undefined
? String(periodicExpenseFilter)
: "all"
}
onValueChange={(val) => { onValueChange={(val) => {
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val)); setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
setPage(1); setPage(1);
}} }}
> >
<SelectTrigger className="w-52"> <SelectTrigger className="w-full sm:w-52">
<SelectValue placeholder="Todos los gastos" /> <SelectValue placeholder="Todos los gastos" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -115,16 +129,27 @@ export function ExpensesTabContent() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm"> <div className="grid grid-cols-2 gap-2 sm:flex">
<Button
onClick={() => setImportDialogOpen(true)}
variant="outline"
size="sm"
className="min-w-0"
>
<Upload className="size-4" /> <Upload className="size-4" />
Importar <span className="truncate">Importar</span>
</Button> </Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm"> <Button
onClick={() => setNewExpenseOpen(true)}
size="sm"
className="min-w-0"
>
<Plus className="size-4" /> <Plus className="size-4" />
Nuevo gasto <span className="truncate">Nuevo gasto</span>
</Button> </Button>
</div> </div>
</div> </div>
</div>
{isLoadingExpenses ? ( {isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p> <p className="text-sm text-muted-foreground">Cargando...</p>
@@ -132,6 +157,7 @@ export function ExpensesTabContent() {
<ExpensesTable <ExpensesTable
data={expenses.data} data={expenses.data}
onPay={setPayDialogExpense} onPay={setPayDialogExpense}
onEdit={setEditDialogExpense}
page={expenses.page} page={expenses.page}
total={expenses.total} total={expenses.total}
pageSize={expenses.pageSize} pageSize={expenses.pageSize}
@@ -149,7 +175,9 @@ export function ExpensesTabContent() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente del Mes</p> <p className="text-sm text-muted-foreground">
Total Pendiente del Mes
</p>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">
{isLoadingTotals {isLoadingTotals
? "..." ? "..."
@@ -157,7 +185,9 @@ export function ExpensesTabContent() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente General</p> <p className="text-sm text-muted-foreground">
Total Pendiente General
</p>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">
{isLoadingTotalPending {isLoadingTotalPending
? "..." ? "..."
@@ -174,19 +204,34 @@ export function ExpensesTabContent() {
}} }}
/> />
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} /> <EditExpenseDialog
expense={editDialogExpense}
open={editDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setEditDialogExpense(null);
}}
/>
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => { <NewExpenseDialog
open={newExpenseOpen}
onOpenChange={setNewExpenseOpen}
/>
<ResponsiveDialog
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open); setImportDialogOpen(open);
if (!open) { if (!open) {
setImportResult(null); setImportResult(null);
} }
}}> }}
>
<ResponsiveDialogContent> <ResponsiveDialogContent>
<ResponsiveDialogHeader> <ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle> <ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
<ResponsiveDialogDescription> <ResponsiveDialogDescription>
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos. Seleccioná un archivo CSV (delimitado por pipes) para importar
gastos.
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
@@ -205,15 +250,20 @@ export function ExpensesTabContent() {
</ul> </ul>
{importResult.errors && importResult.errors.length > 0 && ( {importResult.errors && importResult.errors.length > 0 && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p> <p className="mb-1 text-xs font-medium text-destructive">
Errores:
</p>
<ul className="list-inside list-disc text-xs text-destructive/80"> <ul className="list-inside list-disc text-xs text-destructive/80">
{importResult.errors.map((err, i) => ( {importResult.errors.map((err) => (
<li key={i}>{err}</li> <li key={err}>{err}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}> <Button
className="w-full"
onClick={() => setImportDialogOpen(false)}
>
Cerrar Cerrar
</Button> </Button>
</div> </div>
@@ -236,8 +286,13 @@ export function ExpensesTabContent() {
const result = await importExpenses(file); const result = await importExpenses(file);
setImportResult(result); setImportResult(result);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message =
setImportResult({ imported: 0, skipped: 0, errors: [message] }); err instanceof Error ? err.message : String(err);
setImportResult({
imported: 0,
skipped: 0,
errors: [message],
});
} finally { } finally {
setImporting(false); setImporting(false);
} }

View File

@@ -1,13 +1,19 @@
import { useMemo } from "react";
import { import {
type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
type ColumnDef,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import type { Expense } from "@/lib/api"; import {
ChevronsLeftIcon,
ChevronsRightIcon,
CircleDollarSign,
Pencil,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -16,17 +22,62 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import type { Expense } from "@/lib/api";
interface ExpensesTableProps { interface ExpensesTableProps {
data: Expense[]; data: Expense[];
onPay: (expense: Expense) => void; onPay: (expense: Expense) => void;
onEdit: (expense: Expense) => void;
page: number; page: number;
total: number; total: number;
pageSize: number; pageSize: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
} }
export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) { function formatExpenseDate(value: string) {
return new Date(value).toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function formatExpenseAmount(expense: Expense) {
const isPayed = expense.status === "PAYED";
const value =
isPayed && expense.amountPayed
? Number(expense.amountPayed)
: Number(expense.amount);
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
}
function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
const isPayed = status === "PAYED";
return (
<span
className={
isPayed
? "inline-flex h-5 shrink-0 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "inline-flex h-5 shrink-0 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
}
>
{isPayed ? "Pagado" : "Pendiente"}
</span>
);
}
export function ExpensesTable({
data,
onPay,
onEdit,
page,
total,
pageSize,
onPageChange,
}: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>( const columns = useMemo<ColumnDef<Expense>[]>(
() => [ () => [
{ {
@@ -41,11 +92,26 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
accessorKey: "amount", accessorKey: "amount",
cell: ({ row }) => { cell: ({ row }) => {
const expense = row.original; const expense = row.original;
const isPayed = expense.status === "PAYED"; return (
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount); <span className="tabular-nums">{formatExpenseAmount(expense)}</span>
);
},
},
{
header: "USDC",
accessorKey: "usdcEquivalent",
cell: ({ row }) => {
const value = row.getValue("usdcEquivalent");
if (!value) {
return <span className="text-muted-foreground">--</span>;
}
return ( return (
<span className="tabular-nums"> <span className="tabular-nums">
${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })} {Number(value).toLocaleString("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
USDC
</span> </span>
); );
}, },
@@ -54,15 +120,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Vencimiento", header: "Vencimiento",
accessorKey: "dueDate", accessorKey: "dueDate",
cell: ({ row }) => { cell: ({ row }) => {
const dueDate = new Date(row.getValue("dueDate"));
return ( return (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{dueDate.toLocaleDateString("es-AR", { {formatExpenseDate(row.getValue("dueDate") as string)}
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span> </span>
); );
}, },
@@ -71,18 +131,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Estado", header: "Estado",
accessorKey: "status", accessorKey: "status",
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status");
const isPayed = status === "PAYED";
return ( return (
<span <ExpenseStatusBadge
className={ status={row.getValue("status") as Expense["status"]}
isPayed />
? "inline-flex h-5 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "inline-flex h-5 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
}
>
{isPayed ? "Pagado" : "Pendiente"}
</span>
); );
}, },
}, },
@@ -94,15 +146,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
if (!paymentDate) { if (!paymentDate) {
return <span className="text-muted-foreground">--</span>; return <span className="text-muted-foreground">--</span>;
} }
const date = new Date(paymentDate as string);
return ( return (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{date.toLocaleDateString("es-AR", { {formatExpenseDate(paymentDate as string)}
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span> </span>
); );
}, },
@@ -111,9 +157,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
id: "actions", id: "actions",
cell: ({ row }) => { cell: ({ row }) => {
const expense = row.original; const expense = row.original;
if (expense.status === "PAYED") return null;
return ( return (
<div className="flex justify-end"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="xs" onClick={() => onEdit(expense)}>
<Pencil className="size-3.5" />
Editar
</Button>
{expense.status !== "PAYED" && (
<Button <Button
variant="outline" variant="outline"
size="xs" size="xs"
@@ -122,12 +172,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
<CircleDollarSign className="size-3.5" /> <CircleDollarSign className="size-3.5" />
Pagar Pagar
</Button> </Button>
)}
</div> </div>
); );
}, },
}, },
], ],
[onPay], [onPay, onEdit],
); );
const table = useReactTable({ const table = useReactTable({
@@ -141,10 +192,108 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
const currentGroup = Math.floor((page - 1) / GROUP_SIZE); const currentGroup = Math.floor((page - 1) / GROUP_SIZE);
const startPage = currentGroup * GROUP_SIZE + 1; const startPage = currentGroup * GROUP_SIZE + 1;
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages); const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastItem = Math.min(page * pageSize, total);
const mobilePagination =
total > 0 ? (
<div className="rounded-lg border bg-card p-2 sm:hidden">
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="overflow-x-auto rounded-lg border"> {mobilePagination}
<div className="space-y-2 sm:hidden">
{data.map((expense) => (
<div
key={expense.id}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">{expense.description}</p>
<p className="mt-1 text-xs text-muted-foreground">
Vence {formatExpenseDate(expense.dueDate)}
</p>
</div>
<ExpenseStatusBadge status={expense.status} />
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="text-lg font-semibold tabular-nums">
{formatExpenseAmount(expense)}
</p>
<p className="text-xs text-muted-foreground">
{expense.paymentDate
? `Pagado ${formatExpenseDate(expense.paymentDate)}`
: "Sin fecha de pago"}
</p>
{expense.usdcEquivalent && (
<p className="text-xs text-muted-foreground tabular-nums">
{Number(expense.usdcEquivalent).toLocaleString("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
USDC
</p>
)}
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onEdit(expense)}
>
<Pencil className="size-3.5" />
</Button>
{expense.status !== "PAYED" && (
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
)}
</div>
</div>
</div>
))}
{data.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
No hay gastos para mostrar.
</div>
)}
</div>
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -154,7 +303,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
key={header.id} key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground" className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
> >
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -162,7 +314,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30"> <tr
key={row.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5"> <td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -172,7 +327,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
))} ))}
{table.getRowModel().rows.length === 0 && ( {table.getRowModel().rows.length === 0 && (
<tr> <tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground"> <td
colSpan={columns.length}
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
No hay gastos para mostrar. No hay gastos para mostrar.
</td> </td>
</tr> </tr>
@@ -181,12 +339,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</table> </table>
</div> </div>
{mobilePagination}
{totalPages > 1 && ( {totalPages > 1 && (
<Pagination> <Pagination className="hidden sm:flex">
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(1); }} onClick={(e) => {
e.preventDefault();
onPageChange(1);
}}
href="#" href="#"
aria-label="Ir a la primera página" aria-label="Ir a la primera página"
className={page <= 1 ? "pointer-events-none opacity-50" : ""} className={page <= 1 ? "pointer-events-none opacity-50" : ""}
@@ -196,7 +359,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }} onClick={(e) => {
e.preventDefault();
if (page > 1) onPageChange(page - 1);
}}
href="#" href="#"
className={page <= 1 ? "pointer-events-none opacity-50" : ""} className={page <= 1 ? "pointer-events-none opacity-50" : ""}
/> />
@@ -204,7 +370,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{currentGroup > 0 && ( {currentGroup > 0 && (
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }} onClick={(e) => {
e.preventDefault();
onPageChange(startPage - 1);
}}
href="#" href="#"
aria-label="Ir al grupo anterior" aria-label="Ir al grupo anterior"
> >
@@ -212,11 +381,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
)} )}
{Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => ( {Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i,
).map((p) => (
<PaginationItem key={p}> <PaginationItem key={p}>
<PaginationLink <PaginationLink
isActive={p === page} isActive={p === page}
onClick={(e) => { e.preventDefault(); onPageChange(p); }} onClick={(e) => {
e.preventDefault();
onPageChange(p);
}}
href="#" href="#"
> >
{p} {p}
@@ -226,7 +401,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{(currentGroup + 1) * GROUP_SIZE < totalPages && ( {(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }} onClick={(e) => {
e.preventDefault();
onPageChange(endPage + 1);
}}
href="#" href="#"
aria-label="Ir al siguiente grupo" aria-label="Ir al siguiente grupo"
> >
@@ -236,17 +414,27 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
)} )}
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }} onClick={(e) => {
e.preventDefault();
if (page < totalPages) onPageChange(page + 1);
}}
href="#" href="#"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""} className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
/> />
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }} onClick={(e) => {
e.preventDefault();
onPageChange(totalPages);
}}
href="#" href="#"
aria-label="Ir a la última página" aria-label="Ir a la última página"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""} className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
> >
<SkipForwardIcon className="size-4" /> <SkipForwardIcon className="size-4" />
</PaginationLink> </PaginationLink>

View File

@@ -1,12 +1,12 @@
import { Button } from "@/components/ui/button";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription, ResponsiveDialogDescription,
ResponsiveDialogFooter, ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
interface GenerateCurrentMonthDialogProps { interface GenerateCurrentMonthDialogProps {
open: boolean; open: boolean;
@@ -27,18 +27,19 @@ export function GenerateCurrentMonthDialog({
<ResponsiveDialog open={open} onOpenChange={onOpenChange}> <ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent> <ResponsiveDialogContent>
<ResponsiveDialogHeader> <ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle> <ResponsiveDialogTitle>
¿Generar gasto para este mes?
</ResponsiveDialogTitle>
<ResponsiveDialogDescription> <ResponsiveDialogDescription>
<strong>{description}</strong> aplica al mes actual. ¿Querés generar el gasto ahora? <strong>{description}</strong> aplica al mes actual. ¿Querés generar
el gasto ahora?
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button variant="outline" onClick={onSkip}> <Button variant="outline" onClick={onSkip}>
No, gracias No, gracias
</Button> </Button>
<Button onClick={onConfirm}> <Button onClick={onConfirm}>, generar</Button>
, generar
</Button>
</ResponsiveDialogFooter> </ResponsiveDialogFooter>
</ResponsiveDialogContent> </ResponsiveDialogContent>
</ResponsiveDialog> </ResponsiveDialog>

View File

@@ -1,22 +1,38 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useExpensesContext } from "../ExpensesProvider"; import { useEffect, useMemo, useRef } from "react";
import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const newExpenseSchema = z.object({ const newExpenseSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"), description: z
.string()
.min(1, "La descripción es obligatoria")
.max(50, "Máximo 50 caracteres"),
amount: z.number().positive("El monto debe ser mayor a 0"), amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }), dueDate: z.date({ message: "La fecha es obligatoria" }),
walletId: z.number().positive("Seleccioná una billetera"),
usdcConversionRate: z
.number()
.positive("La cotización debe ser mayor a 0")
.optional(),
}); });
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>; type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
@@ -26,8 +42,24 @@ interface NewExpenseDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) { export function NewExpenseDialog({
open,
onOpenChange,
}: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext(); const { createNonPeriodicExpense } = useExpensesContext();
const prevWalletRef = useRef(0);
const { data: wallets } = useWallets();
const { data: quotes } = useQuotes();
const defaultWallet = useMemo(
() => wallets?.find((w) => w.isDefault),
[wallets],
);
const beloSell = useMemo(() => {
if (!quotes) return null;
const belo = quotes.find((q) => q.type === "BELO");
return belo ? Number(belo.sell) : null;
}, [quotes]);
const { const {
register, register,
@@ -35,15 +67,48 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<NewExpenseFormValues>({ } = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema), resolver: zodResolver(newExpenseSchema),
defaultValues: { defaultValues: {
description: "", description: "",
amount: 0, amount: 0,
dueDate: new Date(), dueDate: new Date(),
walletId: defaultWallet?.id ?? 0,
usdcConversionRate: undefined,
}, },
}); });
useEffect(() => {
if (defaultWallet && !open) return;
if (defaultWallet) {
setValue("walletId", defaultWallet.id);
prevWalletRef.current = defaultWallet.id;
}
}, [defaultWallet, setValue, open]);
const usdcConversionRateField = register("usdcConversionRate", {
setValueAs: (value) => (value === "" ? undefined : Number(value)),
});
const selectedWalletId = useWatch({ control, name: "walletId" });
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
const isUSDC = selectedWallet?.currency === "USDC";
useEffect(() => {
if (
isUSDC &&
beloSell &&
selectedWalletId &&
selectedWalletId !== prevWalletRef.current
) {
setValue("usdcConversionRate", beloSell);
trigger("usdcConversionRate");
prevWalletRef.current = selectedWalletId;
}
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
function handleClose(open: boolean) { function handleClose(open: boolean) {
onOpenChange(open); onOpenChange(open);
if (!open) reset(); if (!open) reset();
@@ -54,6 +119,8 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
description: data.description, description: data.description,
amount: data.amount, amount: data.amount,
dueDate: data.dueDate.toISOString(), dueDate: data.dueDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -65,7 +132,11 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle> <ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form
id="new-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium"> <label htmlFor="description" className="text-sm font-medium">
Descripción Descripción
@@ -77,7 +148,9 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
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" 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 && ( {errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span> <span className="text-xs text-destructive">
{errors.description.message}
</span>
)} )}
</div> </div>
@@ -95,14 +168,70 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
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" 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 && ( {errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span> <span className="text-xs text-destructive">
{errors.amount.message}
</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium"> <label className="text-sm font-medium">Billetera</label>
Fecha del gasto <Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label> </label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
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.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha del gasto</span>
<Controller <Controller
control={control} control={control}
name="dueDate" name="dueDate"
@@ -115,13 +244,19 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
)} )}
/> />
{errors.dueDate && ( {errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span> <span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)} )}
</div> </div>
</form> </form>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" form="new-expense-form" disabled={isSubmitting}> <Button type="submit" form="new-expense-form" disabled={isSubmitting}>

View File

@@ -1,22 +1,38 @@
import { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import type { Expense } from "@/lib/api"; import { useEffect, useMemo, useRef } from "react";
import { useExpensesContext } from "../ExpensesProvider"; import { Controller, useForm, useWatch } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { useQuotes, useWallets } from "@/lib/queries";
const paySchema = z.object({ const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"), amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }), paymentDate: z.date({ message: "La fecha es obligatoria" }),
walletId: z
.number()
.positive("Seleccioná una billetera")
.refine((v) => v > 0, "Seleccioná una billetera"),
usdcConversionRate: z
.number()
.positive("La cotización debe ser mayor a 0")
.optional(),
}); });
type PayFormValues = z.infer<typeof paySchema>; type PayFormValues = z.infer<typeof paySchema>;
@@ -27,8 +43,26 @@ interface PayExpenseDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) { export function PayExpenseDialog({
expense,
open,
onOpenChange,
}: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext(); const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
const prevWalletRef = useRef(0);
const { data: wallets } = useWallets();
const { data: quotes } = useQuotes();
const defaultWallet = useMemo(
() => wallets?.find((w) => w.isDefault),
[wallets],
);
const beloSell = useMemo(() => {
if (!quotes) return null;
const belo = quotes.find((q) => q.type === "BELO");
return belo ? Number(belo.sell) : null;
}, [quotes]);
const { const {
register, register,
@@ -36,22 +70,65 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
reset, reset,
setValue,
trigger,
} = useForm<PayFormValues>({ } = useForm<PayFormValues>({
resolver: zodResolver(paySchema), resolver: zodResolver(paySchema),
defaultValues: { defaultValues: {
amountPayed: 0, amountPayed: 0,
paymentDate: undefined, paymentDate: undefined,
walletId: 0,
usdcConversionRate: undefined,
}, },
}); });
const { ref: amountPayedRef, ...amountPayedField } = register("amountPayed", {
setValueAs: (value) => Number(value),
});
const usdcConversionRateField = register("usdcConversionRate", {
setValueAs: (value) => (value === "" ? undefined : Number(value)),
});
const selectedWalletId = useWatch({ control, name: "walletId" });
const selectedWallet = wallets?.find((w) => w.id === selectedWalletId);
const isUSDC = selectedWallet?.currency === "USDC";
useEffect(() => { useEffect(() => {
if (expense) { if (expense) {
const defaultId = defaultWallet?.id ?? 0;
reset({ reset({
amountPayed: Number(expense.amount), amountPayed: Number(expense.amount),
paymentDate: new Date(), paymentDate: new Date(),
walletId: defaultId,
usdcConversionRate: undefined,
}); });
prevWalletRef.current = defaultId;
} }
}, [expense, reset]); }, [expense, defaultWallet, reset]);
useEffect(() => {
if (
isUSDC &&
beloSell &&
selectedWalletId &&
selectedWalletId !== prevWalletRef.current
) {
setValue("usdcConversionRate", beloSell);
trigger("usdcConversionRate");
prevWalletRef.current = selectedWalletId;
}
}, [isUSDC, beloSell, selectedWalletId, setValue, trigger]);
useEffect(() => {
if (!open || !expense) return;
const frame = requestAnimationFrame(() => {
amountInputRef.current?.focus();
amountInputRef.current?.select();
});
return () => cancelAnimationFrame(frame);
}, [open, expense]);
function handleClose(open: boolean) { function handleClose(open: boolean) {
onOpenChange(open); onOpenChange(open);
@@ -63,6 +140,8 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
await payExpense(expense.id, { await payExpense(expense.id, {
amountPayed: data.amountPayed, amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(), paymentDate: data.paymentDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
}); });
handleClose(false); handleClose(false);
} }
@@ -79,32 +158,99 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm"> <div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span> <span className="font-medium">{expense.description}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })} Monto original: $
{Number(expense.amount).toLocaleString("es-AR", {
minimumFractionDigits: 2,
})}
</span> </span>
</div> </div>
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form
id="pay-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium"> <label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado Monto pagado
</label> </label>
<input <input
id="amountPayed" id="amountPayed"
type="number" type="text"
step="0.01" inputMode="numeric"
min="0" pattern="[0-9]*"
{...register("amountPayed", { valueAsNumber: true })} {...amountPayedField}
ref={(node) => {
amountPayedRef(node);
amountInputRef.current = node;
}}
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" 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.amountPayed && ( {errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span> <span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium"> <label className="text-sm font-medium">Billetera</label>
Fecha de pago <Controller
control={control}
name="walletId"
render={({ field }) => (
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(Number(v))}
>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Seleccionar billetera" />
</SelectTrigger>
<SelectContent>
{wallets?.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.walletId && (
<span className="text-xs text-destructive">
{errors.walletId.message}
</span>
)}
</div>
{isUSDC && (
<div className="flex flex-col gap-1.5">
<label
htmlFor="usdcConversionRate"
className="text-sm font-medium"
>
Cotización USDC (ARS por USDC)
</label> </label>
<input
id="usdcConversionRate"
type="text"
inputMode="decimal"
{...usdcConversionRateField}
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.usdcConversionRate && (
<span className="text-xs text-destructive">
{errors.usdcConversionRate.message}
</span>
)}
<span className="text-xs text-muted-foreground">
Pre-sugerido: {beloSell ?? "—"} ARS/USDC (BELO)
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Fecha de pago</span>
<Controller <Controller
control={control} control={control}
name="paymentDate" name="paymentDate"
@@ -117,13 +263,19 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
)} )}
/> />
{errors.paymentDate && ( {errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span> <span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)} )}
</div> </div>
</form> </form>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" form="pay-form" disabled={isSubmitting}> <Button type="submit" form="pay-form" disabled={isSubmitting}>

View File

@@ -1,8 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { PeriodicExpense } from "@/lib/api";
const months = [ const months = [
{ value: 1, label: "Ene" }, { value: 1, label: "Ene" },
@@ -20,8 +20,15 @@ const months = [
]; ];
const periodicExpenseFormSchema = z.object({ const periodicExpenseFormSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"), description: z
defaultDueDay: z.number().int().min(1, "Día entre 1 y 31").max(31, "Día entre 1 y 31"), .string()
.min(1, "La descripción es obligatoria")
.max(50, "Máximo 50 caracteres"),
defaultDueDay: z
.number()
.int()
.min(1, "Día entre 1 y 31")
.max(31, "Día entre 1 y 31"),
defaultAmount: z.number().positive("El monto debe ser mayor a 0"), defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"), periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
}); });
@@ -34,7 +41,11 @@ interface PeriodicExpenseFormProps {
onCancel: () => void; onCancel: () => void;
} }
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) { export function PeriodicExpenseForm({
initialData,
onSubmit,
onCancel,
}: PeriodicExpenseFormProps) {
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -62,9 +73,15 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
function toggleMonth(month: number) { function toggleMonth(month: number) {
if (selectedPeriods.includes(month)) { if (selectedPeriods.includes(month)) {
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true }); setValue(
"periods",
selectedPeriods.filter((m) => m !== month),
{ shouldValidate: true },
);
} else { } else {
setValue("periods", [...selectedPeriods, month], { shouldValidate: true }); setValue("periods", [...selectedPeriods, month], {
shouldValidate: true,
});
} }
} }
@@ -81,7 +98,9 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
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" 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 && ( {errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span> <span className="text-xs text-destructive">
{errors.description.message}
</span>
)} )}
</div> </div>
@@ -100,7 +119,9 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
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" 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.defaultAmount && ( {errors.defaultAmount && (
<span className="text-xs text-destructive">{errors.defaultAmount.message}</span> <span className="text-xs text-destructive">
{errors.defaultAmount.message}
</span>
)} )}
</div> </div>
@@ -117,14 +138,16 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
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" 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.defaultDueDay && ( {errors.defaultDueDay && (
<span className="text-xs text-destructive">{errors.defaultDueDay.message}</span> <span className="text-xs text-destructive">
{errors.defaultDueDay.message}
</span>
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-sm font-medium">Meses de aplicación</label> <span className="text-sm font-medium">Meses de aplicación</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
@@ -162,13 +185,20 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
))} ))}
</div> </div>
{errors.periods && ( {errors.periods && (
<span className="text-xs text-destructive">{errors.periods.message}</span> <span className="text-xs text-destructive">
{errors.periods.message}
</span>
)} )}
<input type="hidden" {...register("periods")} /> <input type="hidden" {...register("periods")} />
</div> </div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2"> <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}> <Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>

View File

@@ -1,18 +1,18 @@
import { useState, useRef } from "react"; import { Plus, Upload } from "lucide-react";
import { useExpensesContext } from "../ExpensesProvider"; import { useRef, useState } from "react";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable"; import { Button } from "@/components/ui/button";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button"; import type { ImportResult, PeriodicExpense } from "@/lib/api";
import { Plus, Upload } from "lucide-react"; import { useExpensesContext } from "../ExpensesProvider";
import type { PeriodicExpense, ImportResult } from "@/lib/api"; import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
export function PeriodicExpensesTabContent() { export function PeriodicExpensesTabContent() {
const { const {
@@ -53,7 +53,12 @@ export function PeriodicExpensesTabContent() {
setEditingItem(null); setEditingItem(null);
} }
async function handleSubmit(data: { description: string; defaultDueDay: number; defaultAmount: number; periods: number[] }) { async function handleSubmit(data: {
description: string;
defaultDueDay: number;
defaultAmount: number;
periods: number[];
}) {
if (editingItem) { if (editingItem) {
await updatePeriodicExpense(editingItem.id, data); await updatePeriodicExpense(editingItem.id, data);
} else { } else {
@@ -92,18 +97,23 @@ export function PeriodicExpensesTabContent() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="space-y-3 sm:flex sm:items-center sm:justify-between sm:space-y-0">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Administrá tus gastos recurrentes. Administrá tus gastos recurrentes.
</p> </p>
<div className="flex gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm"> <Button
onClick={() => setImportDialogOpen(true)}
variant="outline"
size="sm"
className="min-w-0"
>
<Upload className="size-4" /> <Upload className="size-4" />
Importar <span className="truncate">Importar</span>
</Button> </Button>
<Button onClick={openCreate} size="sm"> <Button onClick={openCreate} size="sm" className="min-w-0">
<Plus className="size-4" /> <Plus className="size-4" />
Nuevo <span className="truncate">Nuevo</span>
</Button> </Button>
</div> </div>
</div> </div>
@@ -135,23 +145,31 @@ export function PeriodicExpensesTabContent() {
<GenerateCurrentMonthDialog <GenerateCurrentMonthDialog
open={generateDialog.open} open={generateDialog.open}
onOpenChange={(open) => setGenerateDialog((prev) => ({ ...prev, open }))} onOpenChange={(open) =>
setGenerateDialog((prev) => ({ ...prev, open }))
}
description={generateDialog.description} description={generateDialog.description}
onConfirm={handleConfirmGenerate} onConfirm={handleConfirmGenerate}
onSkip={handleSkipGenerate} onSkip={handleSkipGenerate}
/> />
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => { <ResponsiveDialog
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open); setImportDialogOpen(open);
if (!open) { if (!open) {
setImportResult(null); setImportResult(null);
} }
}}> }}
>
<ResponsiveDialogContent> <ResponsiveDialogContent>
<ResponsiveDialogHeader> <ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos periódicos</ResponsiveDialogTitle> <ResponsiveDialogTitle>
Importar gastos periódicos
</ResponsiveDialogTitle>
<ResponsiveDialogDescription> <ResponsiveDialogDescription>
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos periódicos. Seleccioná un archivo CSV (delimitado por pipes) para importar
gastos periódicos.
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
@@ -174,15 +192,20 @@ export function PeriodicExpensesTabContent() {
</ul> </ul>
{importResult.errors && importResult.errors.length > 0 && ( {importResult.errors && importResult.errors.length > 0 && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p> <p className="mb-1 text-xs font-medium text-destructive">
Errores:
</p>
<ul className="list-inside list-disc text-xs text-destructive/80"> <ul className="list-inside list-disc text-xs text-destructive/80">
{importResult.errors.map((err, i) => ( {importResult.errors.map((err) => (
<li key={i}>{err}</li> <li key={err}>{err}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}> <Button
className="w-full"
onClick={() => setImportDialogOpen(false)}
>
Cerrar Cerrar
</Button> </Button>
</div> </div>
@@ -205,8 +228,15 @@ export function PeriodicExpensesTabContent() {
const result = await importPeriodicExpenses(file); const result = await importPeriodicExpenses(file);
setImportResult(result); setImportResult(result);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message =
setImportResult({ imported: 0, skipped: 0, skippedNoPeriods: 0, skippedDuplicate: 0, errors: [message] }); err instanceof Error ? err.message : String(err);
setImportResult({
imported: 0,
skipped: 0,
skippedNoPeriods: 0,
skippedDuplicate: 0,
errors: [message],
});
} finally { } finally {
setImporting(false); setImporting(false);
} }

View File

@@ -1,17 +1,28 @@
import { useMemo } from "react";
import { import {
type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
type ColumnDef,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { PeriodicExpense } from "@/lib/api";
const monthLabels = [ const monthLabels = [
"", "Ene", "Feb", "Mar", "Abr", "May", "Jun", "",
"Jul", "Ago", "Sep", "Oct", "Nov", "Dic", "Ene",
"Feb",
"Mar",
"Abr",
"May",
"Jun",
"Jul",
"Ago",
"Sep",
"Oct",
"Nov",
"Dic",
]; ];
function formatPeriods(periods: number[]): string { function formatPeriods(periods: number[]): string {
@@ -28,7 +39,11 @@ function formatPeriods(periods: number[]): string {
if (curr === prev + 1) { if (curr === prev + 1) {
prev = curr; prev = curr;
} else { } else {
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`); ranges.push(
start === prev
? monthLabels[start]
: `${monthLabels[start]}-${monthLabels[prev]}`,
);
start = curr; start = curr;
prev = curr; prev = curr;
} }
@@ -43,7 +58,11 @@ interface PeriodicExpensesTableProps {
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) { export function PeriodicExpensesTable({
data,
onEdit,
onDelete,
}: PeriodicExpensesTableProps) {
const columns = useMemo<ColumnDef<PeriodicExpense>[]>( const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
() => [ () => [
{ {
@@ -114,7 +133,64 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
}); });
return ( return (
<div className="overflow-x-auto rounded-lg border"> <>
<div className="space-y-2 sm:hidden">
{data.map((item) => (
<div key={item.id} className="rounded-lg border bg-card p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">{item.description}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
$
{Number(item.defaultAmount).toLocaleString("es-AR", {
minimumFractionDigits: 2,
})}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
variant="ghost"
size="icon"
aria-label={`Editar ${item.description}`}
onClick={() => onEdit(item)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={`Eliminar ${item.description}`}
onClick={() => onDelete(item.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="rounded-md bg-muted/40 px-2 py-1.5">
<span className="block">Vence</span>
<span className="font-medium text-foreground">
Día {item.defaultDueDay}
</span>
</div>
<div className="min-w-0 rounded-md bg-muted/40 px-2 py-1.5">
<span className="block">Meses</span>
<span className="block truncate font-medium text-foreground">
{formatPeriods(item.periods)}
</span>
</div>
</div>
</div>
))}
{data.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
No hay gastos periódicos todavía.
</div>
)}
</div>
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -124,7 +200,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
key={header.id} key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground" className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
> >
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -132,7 +211,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30"> <tr
key={row.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5"> <td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -142,7 +224,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
))} ))}
{table.getRowModel().rows.length === 0 && ( {table.getRowModel().rows.length === 0 && (
<tr> <tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground"> <td
colSpan={columns.length}
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
No hay gastos periódicos todavía. No hay gastos periódicos todavía.
</td> </td>
</tr> </tr>
@@ -150,5 +235,6 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
</tbody> </tbody>
</table> </table>
</div> </div>
</>
); );
} }

View File

@@ -2,7 +2,9 @@ export function QuotesPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-semibold">Cotizaciones</h1> <h1 className="text-2xl font-semibold">Cotizaciones</h1>
<p className="text-muted-foreground">Creá y administrá tus cotizaciones acá.</p> <p className="text-muted-foreground">
Creá y administrá tus cotizaciones acá.
</p>
</div> </div>
); );
} }

View File

@@ -0,0 +1,516 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { subMonths, startOfMonth } from "date-fns";
import {
ChevronsLeftIcon,
ChevronsRightIcon,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useSearch } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Wallet, WalletMovement, WalletMovementsFilter } from "@/lib/api";
import { useWalletMovementsFiltered, useWallets } from "@/lib/queries";
const amountFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
const dateFormatter = new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const typeLabels: Record<string, string> = {
DEPOSIT: "Depósito",
WITHDRAWAL: "Extracción",
TRANSFER_IN: "Transferencia recibida",
TRANSFER_OUT: "Transferencia enviada",
ADJUSTMENT: "Ajuste",
};
function TypeBadge({ type }: { type: string }) {
const styles: Record<string, string> = {
DEPOSIT:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
WITHDRAWAL:
"bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
TRANSFER_IN:
"bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
TRANSFER_OUT:
"bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
ADJUSTMENT:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
};
return (
<span
className={
`inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-xs font-medium ` +
(styles[type] ?? "")
}
>
{typeLabels[type] ?? type}
</span>
);
}
export function WalletMovementsPage() {
const { walletId: initialWalletId } = useSearch({
from: "/_authenticated/wallets/movements",
});
const { data: wallets } = useWallets();
const [walletId, setWalletId] = useState<number | undefined>(initialWalletId);
const today = new Date();
const [startDate, setStartDate] = useState<Date | undefined>(
startOfMonth(subMonths(today, 1)),
);
const [endDate, setEndDate] = useState<Date | undefined>(today);
const [page, setPage] = useState(1);
const pageSize = 20;
const filter: WalletMovementsFilter = useMemo(
() => ({
walletId,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
page,
pageSize,
}),
[walletId, startDate, endDate, page, pageSize],
);
const { data: result, isLoading } = useWalletMovementsFiltered(filter);
const movements = result?.data ?? [];
const total = result?.total ?? 0;
const columns = useMemo<ColumnDef<WalletMovement>[]>(
() => [
{
header: "Tipo",
accessorKey: "type",
cell: ({ row }) => <TypeBadge type={row.getValue("type") as string} />,
},
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => {
const desc = row.getValue("description") as string | null;
return (
<span className="text-muted-foreground">
{desc ?? (
<span className="italic text-muted-foreground/50">
Sin descripción
</span>
)}
</span>
);
},
},
{
header: "Billetera",
id: "walletName",
cell: ({ row }) => {
const w = wallets?.find(
(w) => w.id === row.original.walletId,
);
return w ? (
<span className="text-xs text-muted-foreground">{w.name}</span>
) : null;
},
},
{
header: "Monto",
accessorKey: "amount",
cell: ({ row }) => {
const amount = row.getValue("amount") as number;
return (
<span
className={`tabular-nums font-medium ${
amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{amount >= 0 ? "+" : ""}
{amountFormatter.format(amount)}
</span>
);
},
},
{
header: "Fecha",
accessorKey: "createdAt",
cell: ({ row }) => {
return (
<span className="text-xs text-muted-foreground tabular-nums">
{dateFormatter.format(
new Date(row.getValue("createdAt") as string),
)}
</span>
);
},
},
],
[wallets],
);
const table = useReactTable({
data: movements,
columns,
getCoreRowModel: getCoreRowModel(),
});
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const GROUP_SIZE = 3;
const currentGroup = Math.floor((page - 1) / GROUP_SIZE);
const startPage = currentGroup * GROUP_SIZE + 1;
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastItem = Math.min(page * pageSize, total);
const mobilePagination =
total > 0 ? (
<div className="rounded-lg border bg-card p-2 sm:hidden">
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Movimientos</h1>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Billetera
</label>
<Select
value={walletId !== undefined ? String(walletId) : "all"}
onValueChange={(v) => {
setWalletId(v === "all" ? undefined : Number(v));
setPage(1);
}}
>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Todas las billeteras" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
Todas las billeteras
</SelectItem>
{wallets?.map((w: Wallet) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name} ({w.currency})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Desde
</label>
<DatePicker
value={startDate}
onChange={(d) => {
setStartDate(d);
setPage(1);
}}
placeholder="Fecha inicial"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Hasta
</label>
<DatePicker
value={endDate}
onChange={(d) => {
setEndDate(d);
setPage(1);
}}
placeholder="Fecha final"
/>
</div>
{(walletId || startDate || endDate) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setWalletId(undefined);
setStartDate(undefined);
setEndDate(undefined);
setPage(1);
}}
>
Limpiar filtros
</Button>
)}
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<>
{mobilePagination}
<div className="space-y-2 sm:hidden">
{movements.map((m) => {
const wallet = wallets?.find((w) => w.id === m.walletId);
return (
<div
key={m.id}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-start justify-between gap-3">
<TypeBadge type={m.type} />
<span
className={`tabular-nums font-medium ${
m.amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400"
}`}
>
{m.amount >= 0 ? "+" : ""}
{amountFormatter.format(m.amount)}
</span>
</div>
<div className="mt-2 space-y-1">
{m.description && (
<p className="text-muted-foreground">{m.description}</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{wallet && <span>{wallet.name}</span>}
<span>&middot;</span>
<span>{dateFormatter.format(new Date(m.createdAt))}</span>
</div>
</div>
</div>
);
})}
{movements.length === 0 && (
<div className="rounded-lg border px-3 py-8 text-center text-sm text-muted-foreground">
No hay movimientos para mostrar.
</div>
)}
</div>
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td
colSpan={columns.length}
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
No hay movimientos para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</div>
{mobilePagination}
{totalPages > 1 && (
<Pagination className="hidden sm:flex">
<PaginationContent>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(1);
}}
href="#"
aria-label="Ir a la primera página"
className={
page <= 1 ? "pointer-events-none opacity-50" : ""
}
>
<SkipBackIcon className="size-4" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={(e) => {
e.preventDefault();
if (page > 1) setPage(page - 1);
}}
href="#"
className={
page <= 1 ? "pointer-events-none opacity-50" : ""
}
text="Anterior"
/>
</PaginationItem>
{currentGroup > 0 && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(startPage - 1);
}}
href="#"
aria-label="Ir al grupo anterior"
>
<ChevronsLeftIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
{Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i,
).map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
onClick={(e) => {
e.preventDefault();
setPage(p);
}}
href="#"
>
{p}
</PaginationLink>
</PaginationItem>
))}
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(endPage + 1);
}}
href="#"
aria-label="Ir al siguiente grupo"
>
<ChevronsRightIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(page + 1);
}}
href="#"
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
text="Siguiente"
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={(e) => {
e.preventDefault();
setPage(totalPages);
}}
href="#"
aria-label="Ir a la última página"
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
>
<SkipForwardIcon className="size-4" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More