Compare commits

...

34 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
Jose Selesan
9482fea7f2 feat(auth): implement better authentication system with user sessions and password management
- Added user, session, account, and verification models to Prisma schema.
- Integrated better-auth for user authentication with email and password.
- Created auth middleware for session validation.
- Implemented auth context and client in frontend for managing user sessions.
- Added login and settings pages for user authentication and password management.
- Updated routes to include authentication checks and user-specific content.
- Enhanced UI components to reflect authentication state.
2026-05-29 16:03:38 -03:00
Jose Selesan
de57ea4bd7 Refactor code structure for improved readability and maintainability 2026-05-29 15:55:59 -03:00
141 changed files with 8000 additions and 20482 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@@ -8,12 +8,18 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"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": {
"@noble/ciphers": "^2.1.1",
"@personal-admin/common": "workspace:*",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"better-auth": "^1.6.11",
"grammy": "^1.44.0",
"hono": "^4.7.0",
"node-cron": "^4.2.1",
"pg": "^8.21.0",

View File

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

View File

@@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

@@ -1,8 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
@@ -12,6 +7,90 @@ datasource db {
provider = "postgresql"
}
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@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 {
BLUE
BNA
@@ -50,6 +129,37 @@ enum PaymentStatus {
PAYED
}
model Wallet {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
currency String @db.VarChar(10)
balance Decimal @db.Decimal(14, 4)
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 {
id Int @id @default(autoincrement())
description String @db.VarChar(50)
@@ -73,9 +183,14 @@ model Expense {
month Int
amount Decimal @db.Money
amountPayed Decimal? @db.Money
beloPrice Decimal? @db.Money
usdcEquivalent Decimal? @db.Decimal(12, 6)
status PaymentStatus @default(PENDING)
dueDate DateTime
paymentDate DateTime?
walletId Int?
wallet Wallet? @relation(fields: [walletId], references: [id])
@@index([walletId])
@@map("expenses")
}

View File

@@ -0,0 +1,62 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "../src/generated/prisma/client";
const adapter = new PrismaPg({
// biome-ignore lint/style/noNonNullAssertion: required env var
connectionString: process.env.DATABASE_URL!,
});
const prisma = new PrismaClient({ adapter });
async function main() {
const existingUser = await prisma.user.findUnique({
where: { email: "jselesan@gmail.com" },
});
if (existingUser) {
console.log("Admin user already exists, skipping seed.");
return;
}
const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
disableSignUp: false,
minPasswordLength: 4,
},
// biome-ignore lint/style/noNonNullAssertion: required env var
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
});
const password = process.env.ADMIN_PASSWORD;
if (!password) {
console.error("ADMIN_PASSWORD environment variable is not set.");
process.exit(1);
}
await auth.api.signUpEmail({
body: {
email: "jselesan@gmail.com",
password,
name: "Admin",
},
});
console.log("Admin user created successfully.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,25 +1,51 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { auth } from "./lib/auth";
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 quotesRouter from "./modules/quotes/quotes.routes";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startExpenseJob } from "./modules/expenses/expense.job";
import { logger } from "./lib/logger";
import telegramRouter from "./modules/telegram/telegram.router";
import walletsRouter from "./modules/wallets/wallets.routes";
const app = new Hono();
app.use("*", requestLogger);
app.get("/api/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
app.use("/api/*", async (c, next) => {
if (c.req.path === "/api/health" || c.req.path.startsWith("/api/auth")) {
return next();
}
return authMiddleware(c, next);
});
app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter);
app.route("/api/telegram", telegramRouter);
app.route("/api/wallets", walletsRouter);
app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" }));
eventBus.on("quotes-updated", () => {
cache.invalidateByPrefix("quotes:");
});
startQuoteJob();
startExpenseJob();
startBot();
logger.info("Backend started");

View File

@@ -0,0 +1,14 @@
import type { Context, Next } from "hono";
import { auth } from "./auth";
export async function authMiddleware(c: Context, next: Next) {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("user", session.user);
c.set("session", session.session);
await next();
}

View File

@@ -0,0 +1,22 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/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({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 4,
},
secret,
baseURL,
trustedOrigins: [baseURL, "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)) {
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);
}
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 { 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 });

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);
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 { 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 { payExpenseHandler } from "./handlers/payExpense";
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
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();
@@ -20,14 +22,19 @@ app.post("/periodic-expenses", createPeriodicExpenseHandler);
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
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/totals", getMonthlyTotalsHandler);
app.get("/expenses/pending-upcoming", getPendingUpcomingExpensesHandler);
app.get("/expenses/pending-total", getTotalPendingHandler);
app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler);
app.post("/expenses/import", importExpensesHandler);
app.put("/expenses/:id/pay", payExpenseHandler);
app.put("/expenses/:id", updateExpenseHandler);
export default app;

View File

@@ -1,6 +1,8 @@
import { z } from "zod";
import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
import { PaymentStatus } from "../../generated/prisma/client";
import { roundTo } from "../../lib/utils";
export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50),
@@ -15,18 +17,42 @@ export const createNonPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50),
amount: z.number().positive(),
dueDate: z.string().datetime(),
walletId: z.number().int().positive(),
usdcConversionRate: z.number().positive().optional(),
});
export const payExpenseSchema = z.object({
amountPayed: z.number().positive(),
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 {
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));
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 };
}
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() {
return prisma.periodicExpense.findMany({
const cached = cache.get("expenses:periodic");
if (cached) return cached;
const data = await prisma.periodicExpense.findMany({
where: { isDeleted: false },
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 { year, month } = getCurrentYearMonthUTC();
const { month } = getCurrentYearMonthUTC();
const currentMonthApplicable = data.periods.includes(month);
cache.invalidateByPrefix("expenses:");
return { ...result, currentMonthApplicable };
}
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
return prisma.periodicExpense.update({ where: { id }, data });
export async function updatePeriodicExpense(
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) {
return prisma.periodicExpense.update({
const result = await prisma.periodicExpense.update({
where: { id },
data: { isDeleted: true },
});
cache.invalidateByPrefix("expenses:");
return result;
}
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 existing = await prisma.expense.findFirst({
@@ -71,7 +170,7 @@ export async function generateMonthlyExpense(id: number) {
if (existing) return existing;
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
return prisma.expense.create({
const result = await prisma.expense.create({
data: {
description: periodic.description,
periodicExpenseId: id,
@@ -82,6 +181,8 @@ export async function generateMonthlyExpense(id: number) {
status: PaymentStatus.PENDING,
},
});
cache.invalidateByPrefix("expenses:");
return result;
}
export async function listExpenses(params: {
@@ -92,6 +193,11 @@ export async function listExpenses(params: {
search?: string;
}) {
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> = {};
if (status === "PENDING" || status === "PAYED") {
where.status = status;
@@ -112,15 +218,39 @@ export async function listExpenses(params: {
}),
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 year = dueDate.getUTCFullYear();
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,
amount: data.amount,
amountPayed: data.amount,
@@ -129,55 +259,300 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
year,
month,
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);
}
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
return prisma.expense.update({
where: { id },
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>,
) {
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,
amountPayed: data.amountPayed,
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) {
const expenses = await prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PAYED },
select: { amountPayed: true },
});
const total = expenses.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0);
return { total };
const cacheKey = `expenses:monthly-total:${year}:${month}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
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) {
const [payed, pending] = await Promise.all([
prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PAYED },
select: { amountPayed: true },
}),
prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PENDING },
select: { amount: true },
}),
]);
return {
payed: payed.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0),
pending: pending.reduce((sum, e) => sum + Number(e.amount), 0),
const cacheKey = `expenses:totals:${year}:${month}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const result = await prisma.$queryRaw<
Array<{ payed: string | null; pending: string | null }>
>`
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
`;
const data = {
payed: Number(result[0]?.payed ?? 0),
pending: Number(result[0]?.pending ?? 0),
};
cache.set(cacheKey, data);
return data;
}
export async function getTotalPending() {
const expenses = await prisma.expense.findMany({
where: { status: PaymentStatus.PENDING },
select: { amount: true },
const cached = cache.get("expenses:pending-total");
if (cached) return cached;
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() {
@@ -210,5 +585,6 @@ export async function generateExpensesForCurrentMonth() {
});
results.push({ id: periodic.id, status: "created" });
}
cache.invalidateByPrefix("expenses:");
return results;
}

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,14 @@ import type { Context } from "hono";
import { getMonthlyTotals } from "../expenses.service";
export async function getMonthlyTotalsHandler(c: Context) {
const year = parseInt(c.req.query("year") ?? String(new Date().getUTCFullYear()), 10);
const month = parseInt(c.req.query("month") ?? String(new Date().getUTCMonth() + 1), 10);
const year = parseInt(
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);
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 { prisma } from "../../../lib/prisma";
import { logger } from "../../../lib/logger";
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 {
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
if (!cleaned) return null;
const n = Number(cleaned);
return isNaN(n) ? null : n;
return Number.isNaN(n) ? null : n;
}
function parseDate(raw: string): Date | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const d = new Date(trimmed);
return isNaN(d.getTime()) ? null : d;
return Number.isNaN(d.getTime()) ? null : d;
}
export async function importExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body["file"];
const file = body.file;
if (!file || !(file instanceof File)) {
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");
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;
@@ -42,7 +46,9 @@ export async function importExpensesHandler(c: Context) {
const parts = line.split("|");
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;
}
@@ -67,7 +73,9 @@ export async function importExpensesHandler(c: Context) {
const paymentDate = parseDate(rawPaymentDate);
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;
}
@@ -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({
data: {
@@ -113,6 +122,8 @@ export async function importExpensesHandler(c: Context) {
}
}
cache.invalidateByPrefix("expenses:");
return c.json({
imported,
skipped,

View File

@@ -1,6 +1,7 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
import { logger } from "../../../lib/logger";
import { prisma } from "../../../lib/prisma";
function parseArgentineAmount(raw: string): number {
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
@@ -15,7 +16,7 @@ function parsePeriods(raw: string): number[] {
export async function importPeriodicExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body["file"];
const file = body.file;
if (!file || !(file instanceof File)) {
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");
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;
@@ -70,8 +74,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
await prisma.periodicExpense.create({
data: {
description,
defaultDueDay: isNaN(defaultDueDay) ? 1 : defaultDueDay,
defaultAmount: isNaN(defaultAmount) ? 0 : defaultAmount,
defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay,
defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount,
periods,
},
});
@@ -84,6 +88,8 @@ export async function importPeriodicExpensesHandler(c: Context) {
}
}
cache.invalidateByPrefix("expenses:");
return c.json({
imported,
skipped: skippedNoPeriods + skippedDuplicate,

View File

@@ -11,7 +11,9 @@ export async function listExpensesHandler(c: Context) {
status,
page,
pageSize,
periodicExpenseId: periodicExpenseId ? parseInt(periodicExpenseId, 10) : undefined,
periodicExpenseId: periodicExpenseId
? parseInt(periodicExpenseId, 10)
: undefined,
search: search || undefined,
});
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 { updatePeriodicExpense, updatePeriodicExpenseSchema } from "../expenses.service";
import {
updatePeriodicExpense,
updatePeriodicExpenseSchema,
} from "../expenses.service";
export async function updatePeriodicExpenseHandler(c: Context) {
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 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();
@@ -62,7 +65,8 @@ export async function processBeloQuote(): Promise<void> {
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
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({

View File

@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
}
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",
headers: {
"User-Agent":
@@ -25,7 +27,8 @@ export async function fetchBlueQuote(): Promise<{ buy: number; sell: number }> {
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
});
},
);
const data: DolaritoApiResponse = await response.json();
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 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();
@@ -76,7 +82,8 @@ export async function processBlueQuote(): Promise<void> {
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
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({

View File

@@ -10,7 +10,9 @@ interface DolaritoApiResponse {
}
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",
headers: {
"User-Agent":
@@ -25,7 +27,8 @@ export async function fetchBnaQuote(): Promise<{ buy: number; sell: number }> {
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
});
},
);
const data: DolaritoApiResponse = await response.json();
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 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();
@@ -76,7 +82,8 @@ export async function processBnaQuote(): Promise<void> {
if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell);
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({

View File

@@ -1,14 +1,18 @@
import type { Context } from "hono";
import { eventBus } from "../../../lib/event-bus";
import { logger } from "../../../lib/logger";
import { processBeloQuote } from "../belo.service";
import { processBlueQuote } from "../blue.service";
import { processBnaQuote } from "../bna.service";
import { logger } from "../../../lib/logger";
import { eventBus } from "../../../lib/event-bus";
export async function fetchQuotes(c: Context) {
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 {
await fn();
results.push({ type: name, status: "ok" });

View File

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

View File

@@ -1,14 +1,21 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
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;
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])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
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 startDateParam = c.req.query("startDate");
@@ -16,23 +23,27 @@ export async function getDailyMinMax(c: Context) {
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : 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);
}
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;
minBuy: number;
maxBuy: number;
minSell: number;
maxSell: number;
}>>`
}>
>`
SELECT
DATE("timeStamp")::text AS date,
MIN(buy::numeric)::float8 AS "minBuy",
@@ -67,5 +78,6 @@ export async function getDailyMinMax(c: Context) {
}
}
cache.set(cacheKey, rows);
return c.json(rows);
}

View File

@@ -1,13 +1,20 @@
import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
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])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
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 startDateParam = c.req.query("startDate");
@@ -15,21 +22,25 @@ export async function getDailyQuotes(c: Context) {
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : 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);
}
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;
buy: number;
sell: number;
}>>`
}>
>`
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
FROM "quotes_history"
@@ -41,5 +52,6 @@ export async function getDailyQuotes(c: Context) {
ORDER BY DATE("timeStamp") ASC
`;
cache.set(cacheKey, 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 { prisma } from "../../../lib/prisma";
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;
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])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
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 type = rawType as QuoteType;
@@ -17,16 +24,18 @@ export async function getQuoteHistory(c: Context) {
const now = new Date();
const startDate = startDateParam
? new Date(startDateParam)
? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : 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);
}
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({
where: {
type,
@@ -35,5 +44,6 @@ export async function getQuoteHistory(c: Context) {
orderBy: { timeStamp: "asc" },
});
cache.set(cacheKey, history);
return c.json(history);
}

View File

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

View File

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

View File

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

@@ -4,6 +4,7 @@
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
{
"hash": "3d534d7c",
"configHash": "c0c89d92",
"lockfileHash": "e6e58bf9",
"browserHash": "5ec6480f",
"optimized": {
"react": {
"src": "../../../../../node_modules/react/index.js",
"file": "react.js",
"fileHash": "fcd31548",
"needsInterop": true
},
"react-dom": {
"src": "../../../../../node_modules/react-dom/index.js",
"file": "react-dom.js",
"fileHash": "ab67f690",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../../../../node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "138cab66",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../../../../node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "9805fa83",
"needsInterop": true
},
"@hookform/resolvers/zod": {
"src": "../../../../../node_modules/@hookform/resolvers/zod/dist/zod.mjs",
"file": "@hookform_resolvers_zod.js",
"fileHash": "194b2aaa",
"needsInterop": false
},
"@radix-ui/react-dialog": {
"src": "../../../../../node_modules/@radix-ui/react-dialog/dist/index.mjs",
"file": "@radix-ui_react-dialog.js",
"fileHash": "a148c7fe",
"needsInterop": false
},
"@tanstack/react-query": {
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
"file": "@tanstack_react-query.js",
"fileHash": "ffafe25c",
"needsInterop": false
},
"@tanstack/react-query-devtools": {
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
"file": "@tanstack_react-query-devtools.js",
"fileHash": "f836306e",
"needsInterop": false
},
"@tanstack/react-router": {
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
"file": "@tanstack_react-router.js",
"fileHash": "5e18d809",
"needsInterop": false
},
"@tanstack/react-table": {
"src": "../../../../../node_modules/@tanstack/react-table/build/lib/index.mjs",
"file": "@tanstack_react-table.js",
"fileHash": "de1dde72",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "79ca80ed",
"needsInterop": false
},
"clsx": {
"src": "../../../../../node_modules/clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "560d7dff",
"needsInterop": false
},
"lucide-react": {
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
"file": "lucide-react.js",
"fileHash": "27bd6cb0",
"needsInterop": false
},
"radix-ui": {
"src": "../../../../../node_modules/radix-ui/dist/index.mjs",
"file": "radix-ui.js",
"fileHash": "f216ffc2",
"needsInterop": false
},
"react-dom/client": {
"src": "../../../../../node_modules/react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "a87aab08",
"needsInterop": true
},
"react-hook-form": {
"src": "../../../../../node_modules/react-hook-form/dist/index.esm.mjs",
"file": "react-hook-form.js",
"fileHash": "72222317",
"needsInterop": false
},
"recharts": {
"src": "../../../../../node_modules/recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "de40db9e",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "d68cea17",
"needsInterop": false
},
"zod": {
"src": "../../../../../node_modules/zod/index.js",
"file": "zod.js",
"fileHash": "c1ae98b2",
"needsInterop": false
},
"date-fns": {
"src": "../../../../../node_modules/date-fns/index.js",
"file": "date-fns.js",
"fileHash": "5586195a",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../../../../node_modules/date-fns/locale.js",
"file": "date-fns_locale.js",
"fileHash": "78139fca",
"needsInterop": false
},
"@radix-ui/react-popover": {
"src": "../../../../../node_modules/@radix-ui/react-popover/dist/index.mjs",
"file": "@radix-ui_react-popover.js",
"fileHash": "1a4bc9a8",
"needsInterop": false
},
"react-day-picker": {
"src": "../../../../../node_modules/react-day-picker/dist/esm/index.js",
"file": "react-day-picker.js",
"fileHash": "a6f8a45a",
"needsInterop": false
}
},
"chunks": {
"ZUJJ2RGI-GFZTQM7R": {
"file": "ZUJJ2RGI-GFZTQM7R.js"
},
"VKXKX7EQ-FAVAKZBN": {
"file": "VKXKX7EQ-FAVAKZBN.js"
},
"chunk-LSVH5W4R": {
"file": "chunk-LSVH5W4R.js"
},
"chunk-G4FIB5Z7": {
"file": "chunk-G4FIB5Z7.js"
},
"chunk-6US6M7KI": {
"file": "chunk-6US6M7KI.js"
},
"chunk-TLZOGT6B": {
"file": "chunk-TLZOGT6B.js"
},
"chunk-O6J3DKHL": {
"file": "chunk-O6J3DKHL.js"
},
"chunk-A2TD7EYA": {
"file": "chunk-A2TD7EYA.js"
},
"chunk-NMJBVCD2": {
"file": "chunk-NMJBVCD2.js"
},
"chunk-X37QSMNJ": {
"file": "chunk-X37QSMNJ.js"
},
"chunk-WFNHCR67": {
"file": "chunk-WFNHCR67.js"
},
"chunk-R6CVIFUM": {
"file": "chunk-R6CVIFUM.js"
},
"chunk-2EY3U74L": {
"file": "chunk-2EY3U74L.js"
},
"chunk-TYPHK3K7": {
"file": "chunk-TYPHK3K7.js"
},
"chunk-ITOVD2G7": {
"file": "chunk-ITOVD2G7.js"
},
"chunk-OAGFOHTQ": {
"file": "chunk-OAGFOHTQ.js"
},
"chunk-SOQCU2R6": {
"file": "chunk-SOQCU2R6.js"
},
"chunk-JCMTJHXH": {
"file": "chunk-JCMTJHXH.js"
},
"chunk-6VO5C3OI": {
"file": "chunk-6VO5C3OI.js"
},
"chunk-WOOG5QLI": {
"file": "chunk-WOOG5QLI.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,11 +1,22 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
import { useAuth } from "@/lib/auth-context";
import { SseProvider } from "@/lib/sse-context";
import { router } from "@/router";
function App() {
const auth = useAuth();
if (auth.isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="size-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<SseProvider>
<RouterProvider router={router} />
<RouterProvider router={router} context={{ auth }} />
</SseProvider>
);
}

View File

@@ -1,7 +1,23 @@
import { Menu, Sun, Moon, Monitor } 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 { useAuth } from "@/lib/auth-context";
import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme";
import { useTelegramStatus } from "@/lib/queries";
import { cn } from "@/lib/utils";
interface HeaderProps {
@@ -11,13 +27,20 @@ interface HeaderProps {
export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus();
const { theme, cycleTheme } = useTheme();
const auth = useAuth();
const { data: telegramStatus } = useTelegramStatus();
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel =
theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema";
async function handleSignOut() {
await auth.signOut();
window.location.href = "/login";
}
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
variant="ghost"
size="icon"
@@ -28,6 +51,21 @@ export function Header({ onMenuClick }: HeaderProps) {
</Button>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
{auth.isAuthenticated && (
<>
<span className="hidden text-sm text-muted-foreground sm:inline">
{auth.session?.user.email}
</span>
<Button
variant="ghost"
size="icon"
onClick={handleSignOut}
title="Cerrar sesión"
>
<LogOut className="size-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
@@ -36,6 +74,18 @@ export function Header({ onMenuClick }: HeaderProps) {
>
<ThemeIcon className="size-4" />
</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
className={cn(
"size-2 rounded-full",

View File

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

View File

@@ -1,14 +1,26 @@
import { Link } from "@tanstack/react-router";
import { FileText, LayoutDashboard, TrendingUp, Wallet } from "lucide-react";
import {
FileText,
Landmark,
LayoutDashboard,
Settings,
TrendingUp,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ to: "/", label: "Panel", icon: LayoutDashboard },
{ to: "/expenses", label: "Gastos", icon: Wallet },
{ to: "/wallets", label: "Billeteras", icon: Landmark },
{ to: "/quotes", label: "Cotizaciones", icon: FileText },
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp },
];
const bottomItems = [
{ to: "/settings", label: "Configuración", icon: Settings },
];
interface SidebarProps {
open: boolean;
onClose: () => void;
@@ -18,9 +30,11 @@ export function Sidebar({ open, onClose }: SidebarProps) {
return (
<>
{open && (
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
<button
type="button"
className="fixed inset-0 z-40 bg-black/20 md:hidden cursor-default"
onClick={onClose}
aria-label="Cerrar menú"
/>
)}
<aside
@@ -39,7 +53,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<Link
key={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"
onClick={onClose}
>
@@ -48,6 +65,23 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</Link>
))}
</nav>
<div className="border-t p-3">
{bottomItems.map((item) => (
<Link
key={item.to}
to={item.to}
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"
onClick={onClose}
>
<item.icon className="size-4" />
{item.label}
</Link>
))}
</div>
</aside>
</>
);

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
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",
@@ -38,8 +38,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -49,9 +49,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -61,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,11 +1,15 @@
"use client"
"use client";
import * as React from "react"
import { DayPicker } from "react-day-picker"
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
ChevronDownIcon,
ChevronLeftIcon,
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({
className,
@@ -37,7 +41,8 @@ function Calendar({
chevron: "size-3 text-muted-foreground",
month_grid: "w-full border-collapse",
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",
day: "p-0 size-8 text-center text-sm",
day_button:
@@ -57,31 +62,37 @@ function Calendar({
}}
components={{
Chevron: (props) => {
const { orientation, ...rest } = props
const { orientation, ...rest } = props;
if (orientation === "up" || orientation === "down") {
return <ChevronDownIcon className="size-3" {...rest} />
return <ChevronDownIcon className="size-3" {...rest} />;
}
const Icon = orientation === "left" ? ChevronLeftIcon : ChevronRightIcon
return <Icon className="size-4" {...rest} />
const Icon =
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className="size-4" {...rest} />;
},
Select: (props) => {
const { className, children, ...rest } = props
const { className, children, ...rest } = props;
return (
<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)}
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")` }}
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,
)}
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}
>
{children}
</select>
)
);
},
}}
{...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 { es } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { format } from "date-fns";
import { es } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface DatePickerProps {
value: Date | undefined
onChange: (date: Date | undefined) => void
placeholder?: string
value: Date | undefined;
onChange: (date: Date | undefined) => void;
placeholder?: string;
}
export function DatePicker({
@@ -25,7 +24,7 @@ export function DatePicker({
onChange,
placeholder = "Seleccionar fecha",
}: DatePickerProps) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -34,7 +33,7 @@ export function DatePicker({
variant="outline"
className={cn(
"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" />
@@ -52,11 +51,11 @@ export function DatePicker({
defaultMonth={value}
captionLayout="dropdown"
onSelect={(date) => {
onChange(date)
setOpen(false)
onChange(date);
setOpen(false);
}}
/>
</PopoverContent>
</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">) {
return (
@@ -9,11 +9,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
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",
className
className,
)}
{...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 { useRouterState } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function LoadingBar() {
const isNavigating = useRouterState({ select: (s) => s.status === "pending" });
const isNavigating = useRouterState({
select: (s) => s.status === "pending",
});
const isFetching = useIsFetching();
const isMutating = useIsMutating();

View File

@@ -1,19 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} 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">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
);
}
function PaginationContent({
@@ -26,17 +28,17 @@ function PaginationContent({
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
React.ComponentProps<"a">;
function PaginationLink({
className,
@@ -58,7 +60,7 @@ function PaginationLink({
{...props}
/>
</Button>
)
);
}
function PaginationPrevious({
@@ -76,7 +78,7 @@ function PaginationPrevious({
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
);
}
function PaginationNext({
@@ -94,7 +96,7 @@ function PaginationNext({
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
);
}
function PaginationEllipsis({
@@ -107,15 +109,14 @@ function PaginationEllipsis({
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<MoreHorizontalIcon
/>
<MoreHorizontalIcon />
<span className="sr-only">More pages</span>
</span>
)
);
}
export {
@@ -126,4 +127,4 @@ export {
PaginationLink,
PaginationNext,
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({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
@@ -37,12 +37,12 @@ function PopoverContent({
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
</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 { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MEDIA_QUERY = "(min-width: 640px)"
const MEDIA_QUERY = "(min-width: 640px)";
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(() => {
if (typeof window === "undefined") return true
return window.matchMedia(query).matches
})
if (typeof window === "undefined") return true;
return window.matchMedia(query).matches;
});
React.useEffect(() => {
const mql = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [query])
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches
return matches;
}
interface ResponsiveDialogContextValue {
open: boolean
onOpenChange: (open: boolean) => void
isDesktop: boolean
open: boolean;
onOpenChange: (open: boolean) => void;
isDesktop: boolean;
}
const ResponsiveDialogContext = React.createContext<ResponsiveDialogContextValue | null>(null)
const ResponsiveDialogContext =
React.createContext<ResponsiveDialogContextValue | null>(null);
function useResponsiveDialog() {
const ctx = React.useContext(ResponsiveDialogContext)
if (!ctx) throw new Error("ResponsiveDialog components must be used within ResponsiveDialog")
return ctx
const ctx = React.useContext(ResponsiveDialogContext);
if (!ctx)
throw new Error(
"ResponsiveDialog components must be used within ResponsiveDialog",
);
return ctx;
}
function ResponsiveDialog({
@@ -43,19 +47,22 @@ function ResponsiveDialog({
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}) {
const [internalOpen, setInternalOpen] = React.useState(false)
const isDesktop = useMediaQuery(MEDIA_QUERY)
const [internalOpen, setInternalOpen] = React.useState(false);
const isDesktop = useMediaQuery(MEDIA_QUERY);
const controlled = open !== undefined
const currentOpen = controlled ? open : internalOpen
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen
const controlled = open !== undefined;
const currentOpen = controlled ? open : internalOpen;
// biome-ignore lint/style/noNonNullAssertion: controlled ensures it's defined
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen;
return (
<ResponsiveDialogContext value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}>
<ResponsiveDialogContext
value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}
>
{isDesktop ? (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children}
@@ -66,7 +73,7 @@ function ResponsiveDialog({
</DialogPrimitive.Root>
)}
</ResponsiveDialogContext>
)
);
}
function ResponsiveDialogTrigger({
@@ -78,7 +85,7 @@ function ResponsiveDialogTrigger({
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
{children}
</DialogPrimitive.Trigger>
)
);
}
function ResponsiveDialogContent({
@@ -86,7 +93,7 @@ function ResponsiveDialogContent({
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
const { isDesktop, open, onOpenChange } = useResponsiveDialog()
const { isDesktop, open, onOpenChange } = useResponsiveDialog();
if (isDesktop) {
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-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",
className
className,
)}
{...props}
>
@@ -120,22 +127,27 @@ function ResponsiveDialogContent({
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
);
}
if (!open) return null
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:hidden" data-slot="responsive-dialog-mobile">
<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)}
aria-label="Cerrar"
/>
<div
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",
"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" />
@@ -152,7 +164,7 @@ function ResponsiveDialogContent({
{children}
</div>
</div>
)
);
}
function ResponsiveDialogHeader({
@@ -165,7 +177,7 @@ function ResponsiveDialogHeader({
className={cn("flex flex-col gap-0.5", className)}
{...props}
/>
)
);
}
function ResponsiveDialogFooter({
@@ -175,10 +187,13 @@ function ResponsiveDialogFooter({
return (
<div
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}
/>
)
);
}
function ResponsiveDialogTitle({
@@ -191,7 +206,7 @@ function ResponsiveDialogTitle({
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
);
}
function ResponsiveDialogDescription({
@@ -204,22 +219,24 @@ function ResponsiveDialogDescription({
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
function ResponsiveDialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
return (
<DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
);
}
export {
ResponsiveDialog,
ResponsiveDialogTrigger,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogFooter,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogClose,
}
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
};

View File

@@ -1,13 +1,12 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
@@ -20,13 +19,13 @@ function SelectGroup({
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
);
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@@ -35,7 +34,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -43,7 +42,7 @@ function SelectTrigger({
data-size={size}
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",
className
className,
)}
{...props}
>
@@ -52,7 +51,7 @@ function SelectTrigger({
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -67,7 +66,12 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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}
align={align}
{...props}
@@ -77,7 +81,7 @@ function SelectContent({
data-position={position}
className={cn(
"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}
@@ -85,7 +89,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -98,7 +102,7 @@ function SelectLabel({
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -111,7 +115,7 @@ function SelectItem({
data-slot="select-item"
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",
className
className,
)}
{...props}
>
@@ -122,7 +126,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -135,7 +139,7 @@ function SelectSeparator({
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -147,14 +151,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronUpIcon
/>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -166,14 +169,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
<ChevronDownIcon
/>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -187,4 +189,4 @@ export {
SelectSeparator,
SelectTrigger,
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({
className,
@@ -16,11 +16,11 @@ function Separator({
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };

View File

@@ -1,32 +1,31 @@
"use client"
"use client";
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -38,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay"
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",
className
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -52,8 +51,8 @@ function SheetContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
@@ -63,7 +62,7 @@ function SheetContent({
data-side={side}
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",
className
className,
)}
{...props}
>
@@ -75,15 +74,14 @@ function SheetContent({
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
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)}
{...props}
/>
)
);
}
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)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -113,13 +111,10 @@ function SheetTitle({
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"text-base font-medium text-foreground",
className
)}
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -132,16 +127,16 @@ function SheetDescription({
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
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({
delayDuration = 0,
@@ -15,19 +15,19 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -43,14 +43,14 @@ function TooltipContent({
sideOffset={sideOffset}
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",
className
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</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 {
LineChart,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
Gauge,
GaugeIndicator,
GaugeTrack,
GaugeRange,
GaugeTrack,
GaugeValueText,
} 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 { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
@@ -39,11 +46,8 @@ function formatTime(iso: string): string {
}
function formatShortDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString("es-AR", {
day: "2-digit",
month: "2-digit",
});
const [, m, d] = iso.split("-");
return `${d}/${m}`;
}
function getGaugeColor(percentage: number | null): string {
@@ -104,15 +108,13 @@ export function BeloPage() {
const now = new Date();
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 { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax(
"BELO",
todayStr,
todayStr,
);
const { data: dailyMinMax } = useDailyMinMax("BELO", todayStr, todayStr);
const { data: monthlyMinMax } = useDailyMinMax(
"BELO",
firstOfMonth,
@@ -139,7 +141,8 @@ export function BeloPage() {
const minSell = maxData ? Number(maxData.minSell) : 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(() => {
if (gaugeValue !== null && maxBuy > minBuy) {
@@ -220,42 +223,40 @@ export function BeloPage() {
return (
<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>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
{belo && (
<span className="text-xs text-muted-foreground">
Últ. actualización: <RelativeTime date={belo.timeStamp} />
</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
type="button"
disabled={isFetching}
onClick={() => doFetchQuotes()}
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
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<StatCard
label="Precio actual"
buy={currentBuy}
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 label="Precio actual" buy={currentBuy} 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
label="Máximo del mes"
buy={monthlyMax?.maxBuy ?? 0}
@@ -274,7 +275,8 @@ export function BeloPage() {
startAngle={0}
endAngle={360}
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}%`;
}}
>
@@ -282,7 +284,12 @@ export function BeloPage() {
<GaugeTrack />
<GaugeRange className={getGaugeColor(gaugePercentage)} />
</GaugeIndicator>
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} />
<GaugeValueText
className={cn(
getGaugeColor(gaugePercentage),
"text-sm font-semibold",
)}
/>
</Gauge>
</div>
</div>
@@ -328,7 +335,10 @@ export function BeloPage() {
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="time"
tick={{ fontSize: 11 }}
@@ -343,11 +353,11 @@ export function BeloPage() {
domain={yDomain}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: recharts types
formatter={(value: any) => [
`$${priceFormatter.format(Number(value))}`,
]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: recharts types
labelFormatter={(label: any) =>
period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
}
@@ -383,7 +393,7 @@ export function BeloPage() {
}}
/>
<Line
type="monotone"
type="linear"
dataKey="buy"
name="Compra"
stroke="var(--chart-1)"
@@ -393,7 +403,7 @@ export function BeloPage() {
isAnimationActive={false}
/>
<Line
type="monotone"
type="linear"
dataKey="sell"
name="Venta"
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 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>
</div>
<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>
</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 { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} 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", {
minimumFractionDigits: 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 diff = quote ? Number(quote.difference) : 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;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: conditional button
<div
className="rounded-lg border p-4 cursor-pointer"
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}
tabIndex={to ? 0 : undefined}
>
@@ -69,7 +89,9 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
<TooltipProvider>
<Tooltip>
<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>
<TooltipContent side="bottom">
<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">
<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 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>
</div>
)
@@ -100,12 +128,27 @@ export function DashboardPage() {
const belo = quotes?.find((q) => q.type === "BELO");
const blue = quotes?.find((q) => q.type === "BLUE");
const bna = quotes?.find((q) => q.type === "BNA");
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 { data: wallets } = useWallets();
const walletBalanceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -116,22 +159,64 @@ export function DashboardPage() {
onClick={() => doFetchQuotes()}
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
</button>
</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">
<p className="text-sm text-muted-foreground">Total ingresos</p>
<p className="text-2xl font-bold">$0.00</p>
<p className="text-sm text-muted-foreground">Pagos pendientes</p>
<p className="text-2xl font-bold">
${priceFormatter.format(monthlyTotals?.pending ?? 0)}
</p>
</div>
<div className="rounded-lg border p-4">
<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>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
<QuoteCard
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>
{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>
);
}

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 { ExpensesProvider } from "./ExpensesProvider";
import { Route } from "@/routes/_authenticated/expenses";
import { ExpensesTabContent } from "./components/ExpensesTabContent";
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
import { ExpensesProvider } from "./ExpensesProvider";
const tabs = [
{ id: "expenses", label: "Gastos" },
@@ -15,9 +16,9 @@ function ExpensesPageInner() {
return (
<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) => (
<button
key={tab.id}
@@ -25,8 +26,8 @@ function ExpensesPageInner() {
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? "-mb-px 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"
? "-mb-px shrink-0 border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
: "shrink-0 px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
}
>
{tab.label}
@@ -41,8 +42,9 @@ function ExpensesPageInner() {
}
export function ExpensesPage() {
const { periodicExpenseId } = Route.useSearch();
return (
<ExpensesProvider>
<ExpensesProvider initialPeriodicExpenseId={periodicExpenseId}>
<ExpensesPageInner />
</ExpensesProvider>
);

View File

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

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 { useExpensesContext } from "../ExpensesProvider";
import { ExpensesTable } from "./ExpensesTable";
import { PayExpenseDialog } from "./PayExpenseDialog";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { Plus, Search, Upload, X } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@@ -19,8 +16,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Upload, Search, X } from "lucide-react";
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", {
minimumFractionDigits: 2,
@@ -33,9 +34,7 @@ export function ExpensesTabContent() {
isLoadingExpenses,
statusFilter,
setStatusFilter,
page,
setPage,
pageSize,
monthlyTotals,
isLoadingTotals,
totalPending,
@@ -48,12 +47,19 @@ export function ExpensesTabContent() {
importExpenses,
} = 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 [importDialogOpen, setImportDialogOpen] = 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 filters = [
@@ -64,21 +70,25 @@ export function ExpensesTabContent() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex gap-1">
<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="scrollbar-none flex gap-1 overflow-x-auto pb-1 sm:pb-0">
{filters.map((f) => (
<Button
key={f.value}
variant={statusFilter === f.value ? "default" : "outline"}
size="xs"
onClick={() => { setStatusFilter(f.value); setPage(1); }}
className="shrink-0"
onClick={() => {
setStatusFilter(f.value);
setPage(1);
}}
>
{f.label}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<div className="relative w-48">
<div className="grid gap-2 sm:flex sm:items-center">
<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" />
<Input
placeholder="Buscar por descripción..."
@@ -97,13 +107,17 @@ export function ExpensesTabContent() {
)}
</div>
<Select
value={periodicExpenseFilter !== undefined ? String(periodicExpenseFilter) : "all"}
value={
periodicExpenseFilter !== undefined
? String(periodicExpenseFilter)
: "all"
}
onValueChange={(val) => {
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
setPage(1);
}}
>
<SelectTrigger className="w-52">
<SelectTrigger className="w-full sm:w-52">
<SelectValue placeholder="Todos los gastos" />
</SelectTrigger>
<SelectContent>
@@ -115,16 +129,27 @@ export function ExpensesTabContent() {
))}
</SelectContent>
</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" />
Importar
<span className="truncate">Importar</span>
</Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Button
onClick={() => setNewExpenseOpen(true)}
size="sm"
className="min-w-0"
>
<Plus className="size-4" />
Nuevo gasto
<span className="truncate">Nuevo gasto</span>
</Button>
</div>
</div>
</div>
{isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
@@ -132,6 +157,7 @@ export function ExpensesTabContent() {
<ExpensesTable
data={expenses.data}
onPay={setPayDialogExpense}
onEdit={setEditDialogExpense}
page={expenses.page}
total={expenses.total}
pageSize={expenses.pageSize}
@@ -149,7 +175,9 @@ export function ExpensesTabContent() {
</p>
</div>
<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">
{isLoadingTotals
? "..."
@@ -157,7 +185,9 @@ export function ExpensesTabContent() {
</p>
</div>
<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">
{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);
if (!open) {
setImportResult(null);
}
}}>
}}
>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos.
Seleccioná un archivo CSV (delimitado por pipes) para importar
gastos.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
@@ -205,15 +250,20 @@ export function ExpensesTabContent() {
</ul>
{importResult.errors && importResult.errors.length > 0 && (
<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">
{importResult.errors.map((err, i) => (
<li key={i}>{err}</li>
{importResult.errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
<Button
className="w-full"
onClick={() => setImportDialogOpen(false)}
>
Cerrar
</Button>
</div>
@@ -236,8 +286,13 @@ export function ExpensesTabContent() {
const result = await importExpenses(file);
setImportResult(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setImportResult({ imported: 0, skipped: 0, errors: [message] });
const message =
err instanceof Error ? err.message : String(err);
setImportResult({
imported: 0,
skipped: 0,
errors: [message],
});
} finally {
setImporting(false);
}

View File

@@ -1,13 +1,19 @@
import { useMemo } from "react";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} 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 { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
import {
Pagination,
PaginationContent,
@@ -16,17 +22,62 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import type { Expense } from "@/lib/api";
interface ExpensesTableProps {
data: Expense[];
onPay: (expense: Expense) => void;
onEdit: (expense: Expense) => void;
page: number;
total: number;
pageSize: number;
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>[]>(
() => [
{
@@ -41,11 +92,26 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
accessorKey: "amount",
cell: ({ row }) => {
const expense = row.original;
const isPayed = expense.status === "PAYED";
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount);
return (
<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 (
<span className="tabular-nums">
${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
{Number(value).toLocaleString("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
USDC
</span>
);
},
@@ -54,15 +120,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Vencimiento",
accessorKey: "dueDate",
cell: ({ row }) => {
const dueDate = new Date(row.getValue("dueDate"));
return (
<span className="text-muted-foreground">
{dueDate.toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
{formatExpenseDate(row.getValue("dueDate") as string)}
</span>
);
},
@@ -71,18 +131,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Estado",
accessorKey: "status",
cell: ({ row }) => {
const status = row.getValue("status");
const isPayed = status === "PAYED";
return (
<span
className={
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>
<ExpenseStatusBadge
status={row.getValue("status") as Expense["status"]}
/>
);
},
},
@@ -94,15 +146,9 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
if (!paymentDate) {
return <span className="text-muted-foreground">--</span>;
}
const date = new Date(paymentDate as string);
return (
<span className="text-muted-foreground">
{date.toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
{formatExpenseDate(paymentDate as string)}
</span>
);
},
@@ -111,9 +157,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
id: "actions",
cell: ({ row }) => {
const expense = row.original;
if (expense.status === "PAYED") return null;
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
variant="outline"
size="xs"
@@ -122,12 +172,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
)}
</div>
);
},
},
],
[onPay],
[onPay, onEdit],
);
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 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={() => onPageChange(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return (
<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">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
@@ -154,7 +303,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
key={header.id}
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>
))}
</tr>
@@ -162,7 +314,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</thead>
<tbody>
{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) => (
<td key={cell.id} className="px-3 py-2.5">
{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 && (
<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.
</td>
</tr>
@@ -181,12 +339,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</table>
</div>
{mobilePagination}
{totalPages > 1 && (
<Pagination>
<Pagination className="hidden sm:flex">
<PaginationContent>
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(1); }}
onClick={(e) => {
e.preventDefault();
onPageChange(1);
}}
href="#"
aria-label="Ir a la primera página"
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
@@ -196,7 +359,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
onClick={(e) => {
e.preventDefault();
if (page > 1) onPageChange(page - 1);
}}
href="#"
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
/>
@@ -204,7 +370,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{currentGroup > 0 && (
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }}
onClick={(e) => {
e.preventDefault();
onPageChange(startPage - 1);
}}
href="#"
aria-label="Ir al grupo anterior"
>
@@ -212,11 +381,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationLink>
</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}>
<PaginationLink
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPageChange(p); }}
onClick={(e) => {
e.preventDefault();
onPageChange(p);
}}
href="#"
>
{p}
@@ -226,7 +401,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }}
onClick={(e) => {
e.preventDefault();
onPageChange(endPage + 1);
}}
href="#"
aria-label="Ir al siguiente grupo"
>
@@ -236,17 +414,27 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
)}
<PaginationItem>
<PaginationNext
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
onClick={(e) => {
e.preventDefault();
if (page < totalPages) onPageChange(page + 1);
}}
href="#"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }}
onClick={(e) => {
e.preventDefault();
onPageChange(totalPages);
}}
href="#"
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" />
</PaginationLink>

View File

@@ -1,12 +1,12 @@
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
interface GenerateCurrentMonthDialogProps {
open: boolean;
@@ -27,18 +27,19 @@ export function GenerateCurrentMonthDialog({
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle>
<ResponsiveDialogTitle>
¿Generar gasto para este mes?
</ResponsiveDialogTitle>
<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>
</ResponsiveDialogHeader>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={onSkip}>
No, gracias
</Button>
<Button onClick={onConfirm}>
, generar
</Button>
<Button onClick={onConfirm}>, generar</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</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 { 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 {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} 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({
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"),
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>;
@@ -26,8 +42,24 @@ interface NewExpenseDialogProps {
onOpenChange: (open: boolean) => void;
}
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
export function NewExpenseDialog({
open,
onOpenChange,
}: NewExpenseDialogProps) {
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 {
register,
@@ -35,15 +67,48 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
control,
formState: { errors, isSubmitting },
reset,
setValue,
trigger,
} = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema),
defaultValues: {
description: "",
amount: 0,
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) {
onOpenChange(open);
if (!open) reset();
@@ -54,6 +119,8 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
description: data.description,
amount: data.amount,
dueDate: data.dueDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
});
handleClose(false);
}
@@ -65,7 +132,11 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</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">
<label htmlFor="description" className="text-sm font-medium">
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"
/>
{errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span>
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</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"
/>
{errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span>
<span className="text-xs text-destructive">
{errors.amount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha del gasto
<label className="text-sm font-medium">Billetera</label>
<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>
<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
control={control}
name="dueDate"
@@ -115,13 +244,19 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
<span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<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 type { Expense } from "@/lib/api";
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 {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} 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({
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
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>;
@@ -27,8 +43,26 @@ interface PayExpenseDialogProps {
onOpenChange: (open: boolean) => void;
}
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
export function PayExpenseDialog({
expense,
open,
onOpenChange,
}: PayExpenseDialogProps) {
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 {
register,
@@ -36,22 +70,65 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
control,
formState: { errors, isSubmitting },
reset,
setValue,
trigger,
} = useForm<PayFormValues>({
resolver: zodResolver(paySchema),
defaultValues: {
amountPayed: 0,
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(() => {
if (expense) {
const defaultId = defaultWallet?.id ?? 0;
reset({
amountPayed: Number(expense.amount),
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) {
onOpenChange(open);
@@ -63,6 +140,8 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
await payExpense(expense.id, {
amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(),
walletId: data.walletId,
usdcConversionRate: isUSDC ? data.usdcConversionRate : undefined,
});
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">
<span className="font-medium">{expense.description}</span>
<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>
</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">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="number"
step="0.01"
min="0"
{...register("amountPayed", { valueAsNumber: true })}
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...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"
/>
{errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
<span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha de pago
<label className="text-sm font-medium">Billetera</label>
<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>
<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
control={control}
name="paymentDate"
@@ -117,13 +263,19 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
<span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<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 { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import type { PeriodicExpense } from "@/lib/api";
const months = [
{ value: 1, label: "Ene" },
@@ -20,8 +20,15 @@ const months = [
];
const periodicExpenseFormSchema = z.object({
description: z.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"),
description: z
.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"),
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
});
@@ -34,7 +41,11 @@ interface PeriodicExpenseFormProps {
onCancel: () => void;
}
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) {
export function PeriodicExpenseForm({
initialData,
onSubmit,
onCancel,
}: PeriodicExpenseFormProps) {
const {
register,
handleSubmit,
@@ -62,9 +73,15 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
function toggleMonth(month: number) {
if (selectedPeriods.includes(month)) {
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true });
setValue(
"periods",
selectedPeriods.filter((m) => m !== month),
{ shouldValidate: true },
);
} 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"
/>
{errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span>
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</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"
/>
{errors.defaultAmount && (
<span className="text-xs text-destructive">{errors.defaultAmount.message}</span>
<span className="text-xs text-destructive">
{errors.defaultAmount.message}
</span>
)}
</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"
/>
{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 className="flex flex-col gap-1.5">
<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">
<button
type="button"
@@ -162,13 +185,20 @@ export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: Periodi
))}
</div>
{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")} />
</div>
<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
</Button>
<Button type="submit" disabled={isSubmitting}>

View File

@@ -1,18 +1,18 @@
import { useState, useRef } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import { Plus, Upload } from "lucide-react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Plus, Upload } from "lucide-react";
import type { PeriodicExpense, ImportResult } from "@/lib/api";
import type { ImportResult, PeriodicExpense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
export function PeriodicExpensesTabContent() {
const {
@@ -53,7 +53,12 @@ export function PeriodicExpensesTabContent() {
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) {
await updatePeriodicExpense(editingItem.id, data);
} else {
@@ -92,18 +97,23 @@ export function PeriodicExpensesTabContent() {
return (
<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">
Administrá tus gastos recurrentes.
</p>
<div className="flex gap-2">
<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" />
Importar
<span className="truncate">Importar</span>
</Button>
<Button onClick={openCreate} size="sm">
<Button onClick={openCreate} size="sm" className="min-w-0">
<Plus className="size-4" />
Nuevo
<span className="truncate">Nuevo</span>
</Button>
</div>
</div>
@@ -135,23 +145,31 @@ export function PeriodicExpensesTabContent() {
<GenerateCurrentMonthDialog
open={generateDialog.open}
onOpenChange={(open) => setGenerateDialog((prev) => ({ ...prev, open }))}
onOpenChange={(open) =>
setGenerateDialog((prev) => ({ ...prev, open }))
}
description={generateDialog.description}
onConfirm={handleConfirmGenerate}
onSkip={handleSkipGenerate}
/>
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => {
<ResponsiveDialog
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open);
if (!open) {
setImportResult(null);
}
}}>
}}
>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos periódicos</ResponsiveDialogTitle>
<ResponsiveDialogTitle>
Importar gastos periódicos
</ResponsiveDialogTitle>
<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>
</ResponsiveDialogHeader>
@@ -174,15 +192,20 @@ export function PeriodicExpensesTabContent() {
</ul>
{importResult.errors && importResult.errors.length > 0 && (
<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">
{importResult.errors.map((err, i) => (
<li key={i}>{err}</li>
{importResult.errors.map((err) => (
<li key={err}>{err}</li>
))}
</ul>
</div>
)}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
<Button
className="w-full"
onClick={() => setImportDialogOpen(false)}
>
Cerrar
</Button>
</div>
@@ -205,8 +228,15 @@ export function PeriodicExpensesTabContent() {
const result = await importPeriodicExpenses(file);
setImportResult(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setImportResult({ imported: 0, skipped: 0, skippedNoPeriods: 0, skippedDuplicate: 0, errors: [message] });
const message =
err instanceof Error ? err.message : String(err);
setImportResult({
imported: 0,
skipped: 0,
skippedNoPeriods: 0,
skippedDuplicate: 0,
errors: [message],
});
} finally {
setImporting(false);
}

View File

@@ -1,17 +1,28 @@
import { useMemo } from "react";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import type { PeriodicExpense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2 } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { PeriodicExpense } from "@/lib/api";
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 {
@@ -28,7 +39,11 @@ function formatPeriods(periods: number[]): string {
if (curr === prev + 1) {
prev = curr;
} else {
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`);
ranges.push(
start === prev
? monthLabels[start]
: `${monthLabels[start]}-${monthLabels[prev]}`,
);
start = curr;
prev = curr;
}
@@ -43,7 +58,11 @@ interface PeriodicExpensesTableProps {
onDelete: (id: number) => void;
}
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) {
export function PeriodicExpensesTable({
data,
onEdit,
onDelete,
}: PeriodicExpensesTableProps) {
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
() => [
{
@@ -114,7 +133,64 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
});
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">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
@@ -124,7 +200,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
key={header.id}
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>
))}
</tr>
@@ -132,7 +211,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
</thead>
<tbody>
{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) => (
<td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -142,7 +224,10 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
))}
{table.getRowModel().rows.length === 0 && (
<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.
</td>
</tr>
@@ -150,5 +235,6 @@ export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpens
</tbody>
</table>
</div>
</>
);
}

View File

@@ -2,7 +2,9 @@ export function QuotesPage() {
return (
<div className="space-y-4">
<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>
);
}

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