Compare commits

..

43 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
Jose Selesan
64ef4440f6 fix(expenses): update amount display logic to reflect payment status 2026-05-29 15:27:57 -03:00
Jose Selesan
e890221c90 fix: usar --linker hoisted en bun install para compatibilidad con Node.js module resolution 2026-05-29 15:14:22 -03:00
Jose Selesan
f0fdbeeb95 fix: crear directorio apps/backend en frontend-build stage para que Vite pueda escribir el build 2026-05-29 15:01:52 -03:00
e9910f16c9 Merge pull request 'feat/expenses-management' (#1) from feat/expenses-management into main
Reviewed-on: #1
2026-05-29 17:57:43 +00:00
Jose Selesan
d3cf386be5 feat(expenses): enhance expenses management with filtering and search capabilities 2026-05-29 14:56:35 -03:00
Jose Selesan
e99e97aa14 Added payment date to expenses table 2026-05-29 14:37:16 -03:00
Jose Selesan
b1968cece9 feat(expenses): add import functionality for expenses and total pending calculation 2026-05-29 14:30:10 -03:00
Jose Selesan
4b17940134 feat(expenses): add monthly totals and pagination support for expenses 2026-05-29 10:59:40 -03:00
Jose Selesan
e1786f7384 feat(expenses): implement responsive dialog for expense management and create expense provider
- Added ResponsiveDialog component for handling responsive dialogs in the UI.
- Created ExpensesProvider to manage state and API interactions for expenses.
- Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions.
- Implemented ExpensesTable for rendering expense data in a tabular format with actions.
- Added dialogs for creating new expenses and paying existing ones with form validation.
- Introduced PeriodicExpenseForm for managing periodic expenses with month selection.
- Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality.
- Added GenerateCurrentMonthDialog for confirming monthly expense generation.
- Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
2026-05-29 10:29:20 -03:00
150 changed files with 11307 additions and 24567 deletions

2
.gitignore vendored
View File

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

View File

@@ -6,7 +6,7 @@ COPY apps/backend/package.json apps/backend/
COPY apps/frontend/package.json apps/frontend/
COPY packages/common/package.json packages/common/
RUN bun install --frozen-lockfile
RUN bun install --frozen-lockfile --linker hoisted
FROM deps AS frontend-build
WORKDIR /app
@@ -14,7 +14,7 @@ WORKDIR /app
COPY apps/frontend/ apps/frontend/
COPY packages/common/ packages/common/
RUN bun run --cwd apps/frontend build
RUN mkdir -p apps/backend && bun run --cwd apps/frontend build
FROM deps AS backend-build
WORKDIR /app

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,17 +8,24 @@
"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",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
"pino-pretty": "^13.1.3",
"zod": "^4.4.3"
},
"prisma": {
"seed": "bun run prisma/seed.ts"

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,34 @@
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAYED');
-- CreateTable
CREATE TABLE "periodic_expenses" (
"id" SERIAL NOT NULL,
"description" VARCHAR(50) NOT NULL,
"defaultDueDay" INTEGER NOT NULL,
"defaultAmount" MONEY NOT NULL,
"periods" INTEGER[],
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "periodic_expenses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "expenses" (
"id" SERIAL NOT NULL,
"description" VARCHAR(50) NOT NULL,
"periodicExpenseId" INTEGER,
"year" INTEGER NOT NULL,
"month" INTEGER NOT NULL,
"amount" MONEY NOT NULL,
"amountPayed" MONEY,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"dueDate" TIMESTAMP(3) NOT NULL,
"paymentDate" TIMESTAMP(3),
CONSTRAINT "expenses_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_periodicExpenseId_fkey" FOREIGN KEY ("periodicExpenseId") REFERENCES "periodic_expenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;

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
@@ -42,3 +121,76 @@ model QuoteHistory {
@@map("quotes_history")
}
// Expenses
enum PaymentStatus {
PENDING
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)
defaultDueDay Int
defaultAmount Decimal @db.Money
periods Int[]
isDeleted Boolean @default(false)
createdAt DateTime @default(now())
expenses Expense[]
@@map("periodic_expenses")
}
model Expense {
id Int @id @default(autoincrement())
description String @db.VarChar(50)
periodicExpense PeriodicExpense? @relation(fields: [periodicExpenseId], references: [id])
periodicExpenseId Int?
year Int
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,21 +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 { 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

@@ -0,0 +1,16 @@
import cron from "node-cron";
import { logger } from "../../lib/logger";
import { generateExpensesForCurrentMonth } from "./expenses.service";
export function startExpenseJob(): void {
cron.schedule("0 1 1 * *", async () => {
logger.info("Generating monthly expenses...");
try {
const results = await generateExpensesForCurrentMonth();
logger.info({ results }, "Monthly expenses generated");
} catch (err) {
logger.error({ err }, "Failed to generate monthly expenses");
}
});
logger.info("Expense cron scheduled: 0 1 1 * *");
}

View File

@@ -0,0 +1,40 @@
import { Hono } from "hono";
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
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 { 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();
app.get("/periodic-expenses", listPeriodicExpensesHandler);
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.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

@@ -0,0 +1,590 @@
import { z } from "zod";
import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
import { roundTo } from "../../lib/utils";
export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50),
defaultDueDay: z.number().int().min(1).max(31),
defaultAmount: z.number().positive(),
periods: z.array(z.number().int().min(1).max(12)).min(1),
});
export const updatePeriodicExpenseSchema = createPeriodicExpenseSchema;
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 {
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
}
export function getCurrentYearMonthUTC(): { year: number; month: number } {
const now = new Date();
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() {
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>,
) {
const result = await prisma.periodicExpense.create({ data });
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>,
) {
const result = await prisma.periodicExpense.update({ where: { id }, data });
cache.invalidateByPrefix("expenses:");
return result;
}
export async function softDeletePeriodicExpense(id: number) {
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 { year, month } = getCurrentYearMonthUTC();
const existing = await prisma.expense.findFirst({
where: { periodicExpenseId: id, year, month },
});
if (existing) return existing;
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
const result = await prisma.expense.create({
data: {
description: periodic.description,
periodicExpenseId: id,
year,
month,
amount: periodic.defaultAmount,
dueDate,
status: PaymentStatus.PENDING,
},
});
cache.invalidateByPrefix("expenses:");
return result;
}
export async function listExpenses(params: {
status?: string;
page?: number;
pageSize?: number;
periodicExpenseId?: number;
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;
}
if (periodicExpenseId !== undefined) {
where.periodicExpenseId = periodicExpenseId;
}
if (search) {
where.description = { contains: search, mode: "insensitive" };
}
const [data, total] = await Promise.all([
prisma.expense.findMany({
where,
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.expense.count({ where }),
]);
const result = { data, total, page, pageSize };
cache.set(cacheKey, result);
return result;
}
export async function createNonPeriodicExpense(
data: z.infer<typeof createNonPeriodicExpenseSchema>,
) {
const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
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,
dueDate,
paymentDate: dueDate,
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);
}
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 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 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 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" },
});
return data.map((expense) => ({
...expense,
dueType:
expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const),
}));
}
export async function generateExpensesForCurrentMonth() {
const { year, month } = getCurrentYearMonthUTC();
const periodics = await prisma.periodicExpense.findMany({
where: { isDeleted: false, periods: { has: month } },
});
const results: Array<{ id: number; status: string }> = [];
for (const periodic of periodics) {
const existing = await prisma.expense.findFirst({
where: { periodicExpenseId: periodic.id, year, month },
});
if (existing) {
results.push({ id: periodic.id, status: "already_exists" });
continue;
}
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
await prisma.expense.create({
data: {
description: periodic.description,
periodicExpenseId: periodic.id,
year,
month,
amount: periodic.defaultAmount,
dueDate,
status: PaymentStatus.PENDING,
},
});
results.push({ id: periodic.id, status: "created" });
}
cache.invalidateByPrefix("expenses:");
return results;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
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 result = await getMonthlyPayedTotal(year, month);
return c.json(result);
}

View File

@@ -0,0 +1,15 @@
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 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

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

View File

@@ -0,0 +1,132 @@
import type { Context } from "hono";
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 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 Number.isNaN(d.getTime()) ? null : d;
}
export async function importExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400);
}
const text = await file.text();
const lines = text.trim().split("\n");
if (lines.length < 2) {
return c.json(
{ error: "El archivo está vacío o solo tiene encabezados" },
400,
);
}
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split("|");
if (parts.length < 10) {
errors.push(
`Línea ${i + 1}: formato inválido (${parts.length} columnas)`,
);
continue;
}
const description = parts[9].trim();
const year = Number(parts[2].trim());
const month = Number(parts[3].trim());
const rawAmount = parts[4].trim();
const rawAmountPayed = parts[5].trim();
const rawStatus = parts[6].trim();
const rawDueDate = parts[7].trim();
const rawPaymentDate = parts[8].trim();
try {
const amount = parseAmount(rawAmount);
if (amount === null) {
errors.push(`Línea ${i + 1} ("${description}"): monto inválido`);
continue;
}
const amountPayed = parseAmount(rawAmountPayed);
const dueDate = parseDate(rawDueDate);
const paymentDate = parseDate(rawPaymentDate);
if (!dueDate) {
errors.push(
`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`,
);
continue;
}
const existing = await prisma.expense.findFirst({
where: { description, year, month },
});
if (existing) {
skipped++;
continue;
}
let periodicExpenseId: number | null = null;
if (parts[1].trim()) {
const periodic = await prisma.periodicExpense.findFirst({
where: { description: { equals: description, mode: "insensitive" } },
});
if (periodic) {
periodicExpenseId = periodic.id;
}
}
const status =
rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
await prisma.expense.create({
data: {
description,
year,
month,
amount,
amountPayed,
status,
dueDate,
paymentDate,
periodicExpenseId,
},
});
imported++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
logger.error({ err, description }, "Error importing expense");
}
}
cache.invalidateByPrefix("expenses:");
return c.json({
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
});
}

View File

@@ -0,0 +1,100 @@
import type { Context } from "hono";
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();
return Number(cleaned);
}
function parsePeriods(raw: string): number[] {
const inner = raw.replace(/^\{|\}$/g, "").trim();
if (!inner) return [];
return inner.split(",").map(Number);
}
export async function importPeriodicExpensesHandler(c: Context) {
const body = await c.req.parseBody();
const file = body.file;
if (!file || !(file instanceof File)) {
return c.json({ error: "No se envió ningún archivo" }, 400);
}
const text = await file.text();
const lines = text.trim().split("\n");
if (lines.length < 2) {
return c.json(
{ error: "El archivo está vacío o solo tiene encabezados" },
400,
);
}
let imported = 0;
let skippedNoPeriods = 0;
let skippedDuplicate = 0;
const errors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split("|");
if (parts.length < 5) {
errors.push(`Línea ${i + 1}: formato inválido`);
continue;
}
const description = parts[1].trim();
const defaultDueDay = Number(parts[2].trim());
const rawAmount = parts[3].trim();
const rawPeriods = parts[4].trim();
const periods = parsePeriods(rawPeriods);
if (periods.length === 0) {
skippedNoPeriods++;
continue;
}
try {
const existing = await prisma.periodicExpense.findFirst({
where: { description: { equals: description, mode: "insensitive" } },
});
if (existing) {
skippedDuplicate++;
continue;
}
const defaultAmount = parseArgentineAmount(rawAmount);
await prisma.periodicExpense.create({
data: {
description,
defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay,
defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount,
periods,
},
});
imported++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
logger.error({ err, description }, "Error importing periodic expense");
}
}
cache.invalidateByPrefix("expenses:");
return c.json({
imported,
skipped: skippedNoPeriods + skippedDuplicate,
skippedNoPeriods,
skippedDuplicate,
errors: errors.length > 0 ? errors : undefined,
});
}

View File

@@ -0,0 +1,20 @@
import type { Context } from "hono";
import { listExpenses } from "../expenses.service";
export async function listExpensesHandler(c: Context) {
const status = c.req.query("status");
const page = parseInt(c.req.query("page") ?? "1", 10);
const pageSize = parseInt(c.req.query("pageSize") ?? "10", 10);
const periodicExpenseId = c.req.query("periodicExpenseId");
const search = c.req.query("search");
const result = await listExpenses({
status,
page,
pageSize,
periodicExpenseId: periodicExpenseId
? parseInt(periodicExpenseId, 10)
: undefined,
search: search || undefined,
});
return c.json(result);
}

View File

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

View File

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

View File

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

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

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

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,130 +0,0 @@
{
"hash": "5f00aae5",
"configHash": "c0c89d92",
"lockfileHash": "9500aa13",
"browserHash": "efa9b066",
"optimized": {
"react": {
"src": "../../../../../node_modules/react/index.js",
"file": "react.js",
"fileHash": "98e991e0",
"needsInterop": true
},
"react-dom": {
"src": "../../../../../node_modules/react-dom/index.js",
"file": "react-dom.js",
"fileHash": "3b7ba340",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../../../../node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "8b662de2",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../../../../node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "edbd65cc",
"needsInterop": true
},
"@tanstack/react-query": {
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
"file": "@tanstack_react-query.js",
"fileHash": "705b896d",
"needsInterop": false
},
"@tanstack/react-query-devtools": {
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
"file": "@tanstack_react-query-devtools.js",
"fileHash": "70485a02",
"needsInterop": false
},
"@tanstack/react-router": {
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
"file": "@tanstack_react-router.js",
"fileHash": "141b1c82",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "6c7abe67",
"needsInterop": false
},
"clsx": {
"src": "../../../../../node_modules/clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "97c8d984",
"needsInterop": false
},
"lucide-react": {
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
"file": "lucide-react.js",
"fileHash": "ad567fd3",
"needsInterop": false
},
"radix-ui": {
"src": "../../../../../node_modules/radix-ui/dist/index.mjs",
"file": "radix-ui.js",
"fileHash": "10864bf0",
"needsInterop": false
},
"react-dom/client": {
"src": "../../../../../node_modules/react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "ab77e82f",
"needsInterop": true
},
"recharts": {
"src": "../../../../../node_modules/recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "d0cc5258",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "f48a862a",
"needsInterop": false
}
},
"chunks": {
"chunk-NMJBVCD2": {
"file": "chunk-NMJBVCD2.js"
},
"chunk-X37QSMNJ": {
"file": "chunk-X37QSMNJ.js"
},
"VKXKX7EQ-FAVAKZBN": {
"file": "VKXKX7EQ-FAVAKZBN.js"
},
"ZUJJ2RGI-GFZTQM7R": {
"file": "ZUJJ2RGI-GFZTQM7R.js"
},
"chunk-LSVH5W4R": {
"file": "chunk-LSVH5W4R.js"
},
"chunk-JCMTJHXH": {
"file": "chunk-JCMTJHXH.js"
},
"chunk-TLZOGT6B": {
"file": "chunk-TLZOGT6B.js"
},
"chunk-A2TD7EYA": {
"file": "chunk-A2TD7EYA.js"
},
"chunk-SOQCU2R6": {
"file": "chunk-SOQCU2R6.js"
},
"chunk-6VO5C3OI": {
"file": "chunk-6VO5C3OI.js"
},
"chunk-WFNHCR67": {
"file": "chunk-WFNHCR67.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

@@ -2,15 +2,15 @@ import {
require_with_selector
} from "./chunk-NMJBVCD2.js";
import "./chunk-X37QSMNJ.js";
import {
clsx
} from "./chunk-WFNHCR67.js";
import {
require_react_dom
} from "./chunk-JCMTJHXH.js";
import {
require_react
} from "./chunk-6VO5C3OI.js";
import {
clsx
} from "./chunk-WFNHCR67.js";
import {
__commonJS,
__export,

View File

@@ -6,24 +6,33 @@
"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",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-query-devtools": "^5.100.11",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.3.0",
"lucide-react": "^1.16.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.76.1",
"recharts": "^3.8.1",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tanstack/react-router": "^1.170.4",

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

@@ -0,0 +1,98 @@
"use client";
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>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("w-full", className)}
classNames={{
root: "w-full",
months: "flex flex-col",
month: "flex flex-col gap-3",
month_caption: "flex items-center justify-center",
caption_label: "hidden",
nav: "flex items-center gap-1",
button_previous:
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
button_next:
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
dropdowns: "flex items-center gap-2",
dropdown_root: "relative inline-flex items-center",
dropdown:
"h-7 w-full appearance-none rounded-md border border-input bg-background pl-2 pr-6 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer",
months_dropdown: "w-28",
years_dropdown: "w-22",
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",
week: "flex w-full",
day: "p-0 size-8 text-center text-sm",
day_button:
"inline-flex items-center justify-center rounded-md size-8 text-sm font-normal text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-selected:opacity-100",
today: "bg-accent text-accent-foreground rounded-md",
outside: "text-muted-foreground opacity-50",
disabled: "text-muted-foreground opacity-50",
range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground rounded-none",
range_start:
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-l-md",
range_end:
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-r-md",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-md",
...classNames,
}}
components={{
Chevron: (props) => {
const { orientation, ...rest } = props;
if (orientation === "up" || orientation === "down") {
return <ChevronDownIcon className="size-3" {...rest} />;
}
const Icon =
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
return <Icon className="size-4" {...rest} />;
},
Select: (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")`,
}}
{...rest}
>
{children}
</select>
);
},
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,61 @@
"use client";
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";
import { cn } from "@/lib/utils";
interface DatePickerProps {
value: Date | undefined;
onChange: (date: Date | undefined) => void;
placeholder?: string;
}
export function DatePicker({
value,
onChange,
placeholder = "Seleccionar fecha",
}: DatePickerProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-8 w-full justify-start gap-2 px-2.5 font-normal",
!value && "text-muted-foreground",
)}
>
<CalendarIcon className="size-4 shrink-0" />
{value ? (
format(value, "PPP", { locale: es })
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={value}
defaultMonth={value}
captionLayout="dropdown"
onSelect={(date) => {
onChange(date);
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,19 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
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,
)}
{...props}
/>
);
}
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

@@ -0,0 +1,130 @@
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
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
asChild
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
>
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
</Button>
);
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
);
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,48 @@
"use client";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"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",
"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",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverContent, PopoverTrigger };

View File

@@ -0,0 +1,242 @@
"use client";
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)";
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(() => {
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]);
return matches;
}
interface ResponsiveDialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
isDesktop: boolean;
}
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;
}
function ResponsiveDialog({
open,
onOpenChange,
children,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}) {
const [internalOpen, setInternalOpen] = React.useState(false);
const isDesktop = useMediaQuery(MEDIA_QUERY);
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 }}
>
{isDesktop ? (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children}
</DialogPrimitive.Root>
) : (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children}
</DialogPrimitive.Root>
)}
</ResponsiveDialogContext>
);
}
function ResponsiveDialogTrigger({
children,
asChild,
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return (
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
{children}
</DialogPrimitive.Trigger>
);
}
function ResponsiveDialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
const { isDesktop, open, onOpenChange } = useResponsiveDialog();
if (isDesktop) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="responsive-dialog-overlay"
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
/>
<DialogPrimitive.Content
data-slot="responsive-dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"flex flex-col gap-4 rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg",
"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,
)}
{...props}
>
{children}
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
if (!open) return null;
return (
<div
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,
)}
>
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="absolute top-3 right-3"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
{children}
</div>
</div>
);
}
function ResponsiveDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-dialog-header"
className={cn("flex flex-col gap-0.5", className)}
{...props}
/>
);
}
function ResponsiveDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-dialog-footer"
className={cn(
"mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
className,
)}
{...props}
/>
);
}
function ResponsiveDialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="responsive-dialog-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
);
}
function ResponsiveDialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="responsive-dialog-description"
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} />
);
}
export {
ResponsiveDialog,
ResponsiveDialogClose,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
};

View File

@@ -0,0 +1,192 @@
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} />;
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
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} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
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,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<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,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
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" && "",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
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,
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
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,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
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,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
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 } 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,9 +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: 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">
@@ -113,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">$0.00</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,8 +1,51 @@
export function ExpensesPage() {
import { useState } from "react";
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" },
{ id: "periodic", label: "Gastos Periódicos" },
] as const;
type TabId = (typeof tabs)[number]["id"];
function ExpensesPageInner() {
const [activeTab, setActiveTab] = useState<TabId>("expenses");
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1>
<p className="text-muted-foreground">Administrá tus gastos acá.</p>
<h1 className="text-2xl font-semibold text-balance">Gastos</h1>
<div className="scrollbar-none flex gap-1 overflow-x-auto border-b">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? "-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}
</button>
))}
</div>
{activeTab === "expenses" && <ExpensesTabContent />}
{activeTab === "periodic" && <PeriodicExpensesTabContent />}
</div>
);
}
export function ExpensesPage() {
const { periodicExpenseId } = Route.useSearch();
return (
<ExpensesProvider initialPeriodicExpenseId={periodicExpenseId}>
<ExpensesPageInner />
</ExpensesProvider>
);
}

View File

@@ -0,0 +1,247 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import type {
CreateNonPeriodicExpenseInput,
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[];
isLoadingPeriodic: boolean;
expenses: PaginatedResponse<Expense>;
isLoadingExpenses: boolean;
statusFilter: string;
setStatusFilter: (status: string) => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
periodicExpenseFilter: number | undefined;
setPeriodicExpenseFilter: (id: number | undefined) => void;
searchInput: string;
setSearchInput: (value: string) => void;
monthlyTotals: MonthlyTotals | undefined;
isLoadingTotals: boolean;
totalPending: MonthlyExpensesTotal | undefined;
isLoadingTotalPending: boolean;
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>;
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,
initialPeriodicExpenseId,
}: {
children: ReactNode;
initialPeriodicExpenseId?: number;
}) {
const [statusFilter, setStatusFilter] = useState("PENDING");
const [page, setPage] = useState(1);
const pageSize = 10;
const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<
number | undefined
>(initialPeriodicExpenseId);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setSearchQuery(searchInput);
}, 300);
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
};
}, [searchInput]);
useEffect(() => {
setPage(1);
}, []);
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 createPeriodicMutation = useCreatePeriodicExpense();
const updatePeriodicMutation = useUpdatePeriodicExpense();
const deletePeriodicMutation = useDeletePeriodicExpense();
const generateMonthlyMutation = useGenerateMonthlyExpense();
const createExpenseMutation = useCreateNonPeriodicExpense();
const payExpenseMutation = usePayExpense();
const updateExpenseMutation = useUpdateExpense();
const importPeriodicMutation = useImportPeriodicExpenses();
const importExpensesMutation = useImportExpenses();
const importPeriodicExpensesFn = useCallback(
async (file: File): Promise<ImportResult> => {
return importPeriodicMutation.mutateAsync(file);
},
[importPeriodicMutation],
);
const importExpensesFn = useCallback(
async (file: File): Promise<ImportExpensesResult> => {
return importExpensesMutation.mutateAsync(file);
},
[importExpensesMutation],
);
const createPeriodicExpense = useCallback(
async (data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> => {
return createPeriodicMutation.mutateAsync(data);
},
[createPeriodicMutation],
);
const updatePeriodicExpenseFn = useCallback(
async (id: number, data: CreatePeriodicExpenseInput) => {
await updatePeriodicMutation.mutateAsync({ id, data });
},
[updatePeriodicMutation],
);
const deletePeriodicExpenseFn = useCallback(
async (id: number) => {
await deletePeriodicMutation.mutateAsync(id);
},
[deletePeriodicMutation],
);
const generateMonthlyExpenseFn = useCallback(
async (id: number): Promise<Expense> => {
return generateMonthlyMutation.mutateAsync(id);
},
[generateMonthlyMutation],
);
const createNonPeriodicExpenseFn = useCallback(
async (data: CreateNonPeriodicExpenseInput) => {
await createExpenseMutation.mutateAsync(data);
},
[createExpenseMutation],
);
const payExpenseFn = useCallback(
async (id: number, data: PayExpenseInput) => {
await payExpenseMutation.mutateAsync({ id, data });
},
[payExpenseMutation],
);
const updateExpenseFn = useCallback(
async (id: number, data: UpdateExpenseInput) => {
await updateExpenseMutation.mutateAsync({ id, data });
},
[updateExpenseMutation],
);
return (
<ExpensesContext
value={{
periodicExpenses,
isLoadingPeriodic,
expenses,
isLoadingExpenses,
statusFilter,
setStatusFilter,
page,
setPage,
pageSize,
periodicExpenseFilter,
setPeriodicExpenseFilter,
searchInput,
setSearchInput,
monthlyTotals,
isLoadingTotals,
totalPending,
isLoadingTotalPending,
createPeriodicExpense,
updatePeriodicExpense: updatePeriodicExpenseFn,
deletePeriodicExpense: deletePeriodicExpenseFn,
generateMonthlyExpense: generateMonthlyExpenseFn,
createNonPeriodicExpense: createNonPeriodicExpenseFn,
payExpense: payExpenseFn,
updateExpense: updateExpenseFn,
importPeriodicExpenses: importPeriodicExpensesFn,
importExpenses: importExpensesFn,
}}
>
{children}
</ExpensesContext>
);
}
export function useExpensesContext() {
const ctx = useContext(ExpensesContext);
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>
);
}

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