Compare commits

..

37 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
142 changed files with 8003 additions and 20483 deletions

2
.gitignore vendored
View File

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

View File

@@ -6,7 +6,7 @@ COPY apps/backend/package.json apps/backend/
COPY apps/frontend/package.json apps/frontend/ COPY apps/frontend/package.json apps/frontend/
COPY packages/common/package.json packages/common/ COPY packages/common/package.json packages/common/
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile --linker hoisted
FROM deps AS frontend-build FROM deps AS frontend-build
WORKDIR /app WORKDIR /app
@@ -14,7 +14,7 @@ WORKDIR /app
COPY apps/frontend/ apps/frontend/ COPY apps/frontend/ apps/frontend/
COPY packages/common/ packages/common/ 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 FROM deps AS backend-build
WORKDIR /app WORKDIR /app

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../src/generated/prisma" output = "../src/generated/prisma"
@@ -12,6 +7,90 @@ datasource db {
provider = "postgresql" 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 { enum QuoteType {
BLUE BLUE
BNA BNA
@@ -50,6 +129,37 @@ enum PaymentStatus {
PAYED PAYED
} }
model Wallet {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
currency String @db.VarChar(10)
balance Decimal @db.Decimal(14, 4)
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
movements WalletMovement[]
expenses Expense[]
@@map("wallets")
}
model WalletMovement {
id Int @id @default(autoincrement())
walletId Int
wallet Wallet @relation(fields: [walletId], references: [id])
type String @db.VarChar(20)
amount Decimal @db.Decimal(14, 4)
description String? @db.VarChar(255)
referenceWalletId Int?
transferGroupId String? @db.Uuid
createdAt DateTime @default(now())
@@index([walletId])
@@index([transferGroupId])
@@map("wallet_movements")
}
model PeriodicExpense { model PeriodicExpense {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
description String @db.VarChar(50) description String @db.VarChar(50)
@@ -73,9 +183,14 @@ model Expense {
month Int month Int
amount Decimal @db.Money amount Decimal @db.Money
amountPayed Decimal? @db.Money amountPayed Decimal? @db.Money
beloPrice Decimal? @db.Money
usdcEquivalent Decimal? @db.Decimal(12, 6)
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
dueDate DateTime dueDate DateTime
paymentDate DateTime? paymentDate DateTime?
walletId Int?
wallet Wallet? @relation(fields: [walletId], references: [id])
@@index([walletId])
@@map("expenses") @@map("expenses")
} }

View File

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

View File

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

View File

@@ -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)) { if (!this.listeners.has(event)) {
this.listeners.set(event, new Set()); this.listeners.set(event, new Set());
} }
this.listeners.get(event)!.add(listener); this.listeners.get(event)?.add(listener);
return () => this.listeners.get(event)?.delete(listener); return () => this.listeners.get(event)?.delete(listener);
} }
emit(event: string, ...args: unknown[]): void { emit(event: string, ...args: unknown[]): void {
this.listeners.get(event)?.forEach((listener) => listener(...args)); this.listeners.get(event)?.forEach((listener) => {
listener(...args);
});
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
import { Hono } from "hono";
import { adjustBalanceHandler } from "./handlers/adjustBalance";
import { createWalletHandler } from "./handlers/createWallet";
import { deleteWalletHandler } from "./handlers/deleteWallet";
import { depositWalletHandler } from "./handlers/depositWallet";
import { listAllMovementsHandler } from "./handlers/listAllMovements";
import { listMovementsHandler } from "./handlers/listMovements";
import { listWalletsHandler } from "./handlers/listWallets";
import { transferWalletHandler } from "./handlers/transferWallet";
import { updateWalletHandler } from "./handlers/updateWallet";
import { setDefaultWalletHandler } from "./handlers/setDefaultWallet";
const app = new Hono();
app.get("/movements", listAllMovementsHandler);
app.get("/", listWalletsHandler);
app.post("/", createWalletHandler);
app.put("/:id", updateWalletHandler);
app.delete("/:id", deleteWalletHandler);
app.post("/:id/deposit", depositWalletHandler);
app.post("/:id/adjust", adjustBalanceHandler);
app.post("/transfer", transferWalletHandler);
app.put("/:id/default", setDefaultWalletHandler);
app.get("/:id/movements", listMovementsHandler);
export default app;

View File

@@ -0,0 +1,243 @@
import { z } from "zod";
import { Prisma } from "../../generated/prisma/client";
import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
export const createWalletSchema = z.object({
name: z.string().min(1).max(100),
currency: z.string().min(1).max(10),
initialBalance: z.number().optional().default(0),
isDefault: z.boolean().optional().default(false),
});
export const updateWalletSchema = z.object({
name: z.string().min(1).max(100),
currency: z.string().min(1).max(10),
});
export const setDefaultWalletSchema = z.object({
walletId: z.number().int().positive(),
});
export const depositSchema = z.object({
amount: z.number().positive(),
description: z.string().max(255).optional(),
});
export const adjustBalanceSchema = z.object({
newBalance: z.number().min(0),
description: z.string().max(255).optional(),
});
export const transferSchema = z.object({
fromWalletId: z.number().int().positive(),
toWalletId: z.number().int().positive(),
outgoingAmount: z.number().positive(),
incomingAmount: z.number().positive(),
description: z.string().max(255).optional(),
});
export type CreateWalletInput = z.infer<typeof createWalletSchema>;
export type UpdateWalletInput = z.infer<typeof updateWalletSchema>;
export type DepositInput = z.infer<typeof depositSchema>;
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
export type TransferInput = z.infer<typeof transferSchema>;
export type SetDefaultWalletInput = z.infer<typeof setDefaultWalletSchema>;
export async function listWallets() {
const wallets = await prisma.wallet.findMany({
orderBy: [{ isDefault: "desc" }, { createdAt: "desc" }],
});
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
}
export async function createWallet(input: CreateWalletInput) {
const wallet = await prisma.wallet.create({
data: {
name: input.name,
currency: input.currency,
balance: input.initialBalance ?? 0,
isDefault: input.isDefault ?? false,
},
});
if (wallet.isDefault) {
await prisma.wallet.updateMany({
where: { id: { not: wallet.id }, isDefault: true },
data: { isDefault: false },
});
}
return { ...wallet, balance: Number(wallet.balance) };
}
export async function updateWallet(id: number, input: UpdateWalletInput) {
const wallet = await prisma.wallet.update({
where: { id },
data: { name: input.name, currency: input.currency },
});
return { ...wallet, balance: Number(wallet.balance) };
}
export async function deleteWallet(id: number) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
if (Number(wallet.balance) !== 0) {
throw new Error("No se puede eliminar una billetera con saldo distinto de cero");
}
await prisma.walletMovement.deleteMany({ where: { walletId: id } });
await prisma.wallet.delete({ where: { id } });
}
export async function depositWallet(id: number, input: DepositInput) {
const [wallet] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: { increment: input.amount } },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "DEPOSIT",
amount: input.amount,
description: input.description ?? null,
},
}),
]);
return { ...wallet, balance: Number(wallet.balance) };
}
export async function adjustBalance(id: number, input: AdjustBalanceInput) {
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
const currentBalance = Number(wallet.balance);
const difference = input.newBalance - currentBalance;
const [updated] = await prisma.$transaction([
prisma.wallet.update({
where: { id },
data: { balance: input.newBalance },
}),
prisma.walletMovement.create({
data: {
walletId: id,
type: "ADJUSTMENT",
amount: difference,
description: input.description ?? null,
},
}),
]);
return { ...updated, balance: Number(updated.balance) };
}
export async function transferWallet(input: TransferInput) {
const groupId = crypto.randomUUID();
const [fromWallet, toWallet] = await prisma.$transaction(async (tx) => {
const from = await tx.wallet.update({
where: { id: input.fromWalletId },
data: { balance: { decrement: input.outgoingAmount } },
});
const to = await tx.wallet.update({
where: { id: input.toWalletId },
data: { balance: { increment: input.incomingAmount } },
});
await tx.walletMovement.create({
data: {
walletId: input.fromWalletId,
type: "TRANSFER_OUT",
amount: -input.outgoingAmount,
description: input.description ?? null,
referenceWalletId: input.toWalletId,
transferGroupId: groupId,
},
});
await tx.walletMovement.create({
data: {
walletId: input.toWalletId,
type: "TRANSFER_IN",
amount: input.incomingAmount,
description: input.description ?? null,
referenceWalletId: input.fromWalletId,
transferGroupId: groupId,
},
});
return [from, to];
});
return {
from: { ...fromWallet, balance: Number(fromWallet.balance) },
to: { ...toWallet, balance: Number(toWallet.balance) },
};
}
export async function setDefaultWallet(walletId: number) {
await prisma.$transaction([
prisma.wallet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
}),
prisma.wallet.update({
where: { id: walletId },
data: { isDefault: true },
}),
]);
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id: walletId } });
return { ...wallet, balance: Number(wallet.balance) };
}
export async function listMovements(walletId: number) {
const movements = await prisma.walletMovement.findMany({
where: { walletId },
orderBy: { createdAt: "desc" },
});
return movements.map((m) => ({ ...m, amount: Number(m.amount) }));
}
export async function listAllMovements(params: {
walletId?: number;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}) {
const { walletId, startDate, endDate, page = 1, pageSize = 20 } = params;
const cacheKey = `movements:all:${walletId ?? "all"}:${startDate ?? ""}:${endDate ?? ""}:${page}:${pageSize}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const where: Record<string, unknown> = {};
if (walletId !== undefined) {
where.walletId = walletId;
}
if (startDate || endDate) {
const createdAt: Record<string, Date> = {};
if (startDate) createdAt.gte = new Date(startDate);
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
createdAt.lte = end;
}
where.createdAt = createdAt;
}
const [data, total] = await Promise.all([
prisma.walletMovement.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.walletMovement.count({ where }),
]);
const result = {
data: data.map((m) => ({ ...m, amount: Number(m.amount) })),
total,
page,
pageSize,
};
cache.set(cacheKey, result);
return result;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,11 +1,22 @@
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router"; import { useAuth } from "@/lib/auth-context";
import { SseProvider } from "@/lib/sse-context"; import { SseProvider } from "@/lib/sse-context";
import { router } from "@/router";
function App() { 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 ( return (
<SseProvider> <SseProvider>
<RouterProvider router={router} /> <RouterProvider router={router} context={{ auth }} />
</SseProvider> </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 { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth-context";
import { useSseStatus } from "@/lib/sse-context"; import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
import { useTelegramStatus } from "@/lib/queries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
@@ -11,13 +27,20 @@ interface HeaderProps {
export function Header({ onMenuClick }: HeaderProps) { export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus(); const sseStatus = useSseStatus();
const { theme, cycleTheme } = useTheme(); const { theme, cycleTheme } = useTheme();
const auth = useAuth();
const { data: telegramStatus } = useTelegramStatus();
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = const themeLabel =
theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema"; theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema";
async function handleSignOut() {
await auth.signOut();
window.location.href = "/login";
}
return ( return (
<header className="flex h-14 items-center gap-4 border-b px-4"> <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -28,6 +51,21 @@ export function Header({ onMenuClick }: HeaderProps) {
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-1.5"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -36,6 +74,18 @@ export function Header({ onMenuClick }: HeaderProps) {
> >
<ThemeIcon className="size-4" /> <ThemeIcon className="size-4" />
</Button> </Button>
<Link
to="/settings"
className={cn(
"inline-flex items-center justify-center rounded-md p-1.5 transition-colors",
telegramStatus?.connected
? "text-emerald-500 hover:text-emerald-400"
: "text-muted-foreground hover:text-foreground",
)}
title={telegramStatus?.connected ? "Telegram conectado" : "Conectar Telegram"}
>
<TelegramIcon className="size-4" />
</Link>
<span <span
className={cn( className={cn(
"size-2 rounded-full", "size-2 rounded-full",

View File

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

View File

@@ -1,14 +1,26 @@
import { Link } from "@tanstack/react-router"; 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"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ to: "/", label: "Panel", icon: LayoutDashboard }, { to: "/", label: "Panel", icon: LayoutDashboard },
{ to: "/expenses", label: "Gastos", icon: Wallet }, { to: "/expenses", label: "Gastos", icon: Wallet },
{ to: "/wallets", label: "Billeteras", icon: Landmark },
{ to: "/quotes", label: "Cotizaciones", icon: FileText }, { to: "/quotes", label: "Cotizaciones", icon: FileText },
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp }, { to: "/quotes/belo", label: "BELO", icon: TrendingUp },
]; ];
const bottomItems = [
{ to: "/settings", label: "Configuración", icon: Settings },
];
interface SidebarProps { interface SidebarProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -18,9 +30,11 @@ export function Sidebar({ open, onClose }: SidebarProps) {
return ( return (
<> <>
{open && ( {open && (
<div <button
className="fixed inset-0 z-40 bg-black/20 md:hidden" type="button"
className="fixed inset-0 z-40 bg-black/20 md:hidden cursor-default"
onClick={onClose} onClick={onClose}
aria-label="Cerrar menú"
/> />
)} )}
<aside <aside
@@ -39,7 +53,10 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }} activeProps={{
className:
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
}}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={onClose} onClick={onClose}
> >
@@ -48,6 +65,23 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</Link> </Link>
))} ))}
</nav> </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> </aside>
</> </>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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