Compare commits
45 Commits
bd5e29236b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4784242a35 | ||
| 09f00aa3b9 | |||
|
|
04b3f87aca | ||
|
|
33120deed6 | ||
|
|
5671b4a14b | ||
| 798d657869 | |||
|
|
1d954217b7 | ||
|
|
1ff2abc16a | ||
|
|
b8d8f16f4e | ||
|
|
c0c8bf0945 | ||
|
|
142fd4e33f | ||
|
|
b08399908b | ||
|
|
6f06ee18da | ||
|
|
b4f4030ee8 | ||
|
|
824f3a18ab | ||
|
|
0915ad1223 | ||
|
|
7c864053bb | ||
|
|
9ce5511ad8 | ||
|
|
55098a7b95 | ||
|
|
5b6ccf0fa6 | ||
|
|
53a797703e | ||
| 33248401f7 | |||
|
|
8b8a4670cb | ||
|
|
bd53b26f29 | ||
|
|
2fd23cedc6 | ||
|
|
988d58d761 | ||
|
|
801f04df12 | ||
|
|
f6c03e3c91 | ||
|
|
3daf67639a | ||
|
|
94aab6dce7 | ||
|
|
98b6d7e4d3 | ||
|
|
629203dac3 | ||
|
|
7702eb054c | ||
| 067710f1ae | |||
|
|
9482fea7f2 | ||
|
|
de57ea4bd7 | ||
|
|
64ef4440f6 | ||
|
|
e890221c90 | ||
|
|
f0fdbeeb95 | ||
| e9910f16c9 | |||
|
|
d3cf386be5 | ||
|
|
e99e97aa14 | ||
|
|
b1968cece9 | ||
|
|
4b17940134 | ||
|
|
e1786f7384 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ node_modules
|
|||||||
.env.local
|
.env.local
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
.vite
|
||||||
|
.tanstack
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -8,17 +8,24 @@
|
|||||||
"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",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3"
|
"pino-pretty": "^13.1.3",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "bun run prisma/seed.ts"
|
"seed": "bun run prisma/seed.ts"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAYED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "periodic_expenses" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"description" VARCHAR(50) NOT NULL,
|
||||||
|
"defaultDueDay" INTEGER NOT NULL,
|
||||||
|
"defaultAmount" MONEY NOT NULL,
|
||||||
|
"periods" INTEGER[],
|
||||||
|
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "periodic_expenses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "expenses" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"description" VARCHAR(50) NOT NULL,
|
||||||
|
"periodicExpenseId" INTEGER,
|
||||||
|
"year" INTEGER NOT NULL,
|
||||||
|
"month" INTEGER NOT NULL,
|
||||||
|
"amount" MONEY NOT NULL,
|
||||||
|
"amountPayed" MONEY,
|
||||||
|
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"dueDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"paymentDate" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "expenses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "expenses" ADD CONSTRAINT "expenses_periodicExpenseId_fkey" FOREIGN KEY ("periodicExpenseId") REFERENCES "periodic_expenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "expenses" ADD COLUMN "beloPrice" MONEY,
|
||||||
|
ADD COLUMN "usdcEquivalent" DECIMAL(12,6);
|
||||||
@@ -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");
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "wallets" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "settings" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed
|
||||||
|
INSERT INTO "settings" ("key", "value", "createdAt", "updatedAt")
|
||||||
|
VALUES ('salary_multiplier', '4500', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
@@ -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
|
||||||
@@ -42,3 +121,85 @@ model QuoteHistory {
|
|||||||
|
|
||||||
@@map("quotes_history")
|
@@map("quotes_history")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expenses
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDING
|
||||||
|
PAYED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Wallet {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
currency String @db.VarChar(10)
|
||||||
|
balance Decimal @db.Decimal(14, 4)
|
||||||
|
isDefault Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
movements WalletMovement[]
|
||||||
|
expenses Expense[]
|
||||||
|
|
||||||
|
@@map("wallets")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WalletMovement {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
walletId Int
|
||||||
|
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||||
|
type String @db.VarChar(20)
|
||||||
|
amount Decimal @db.Decimal(14, 4)
|
||||||
|
description String? @db.VarChar(255)
|
||||||
|
referenceWalletId Int?
|
||||||
|
transferGroupId String? @db.Uuid
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([walletId])
|
||||||
|
@@index([transferGroupId])
|
||||||
|
@@map("wallet_movements")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PeriodicExpense {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
description String @db.VarChar(50)
|
||||||
|
defaultDueDay Int
|
||||||
|
defaultAmount Decimal @db.Money
|
||||||
|
periods Int[]
|
||||||
|
isDeleted Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
expenses Expense[]
|
||||||
|
|
||||||
|
@@map("periodic_expenses")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Expense {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
description String @db.VarChar(50)
|
||||||
|
periodicExpense PeriodicExpense? @relation(fields: [periodicExpenseId], references: [id])
|
||||||
|
periodicExpenseId Int?
|
||||||
|
year Int
|
||||||
|
month Int
|
||||||
|
amount Decimal @db.Money
|
||||||
|
amountPayed Decimal? @db.Money
|
||||||
|
beloPrice Decimal? @db.Money
|
||||||
|
usdcEquivalent Decimal? @db.Decimal(12, 6)
|
||||||
|
status PaymentStatus @default(PENDING)
|
||||||
|
dueDate DateTime
|
||||||
|
paymentDate DateTime?
|
||||||
|
walletId Int?
|
||||||
|
wallet Wallet? @relation(fields: [walletId], references: [id])
|
||||||
|
|
||||||
|
@@index([walletId])
|
||||||
|
@@map("expenses")
|
||||||
|
}
|
||||||
62
apps/backend/prisma/seed.ts
Normal file
62
apps/backend/prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -1,21 +1,53 @@
|
|||||||
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 { logger } from "./lib/logger";
|
import settingsRouter from "./modules/settings/settings.routes";
|
||||||
|
import telegramRouter from "./modules/telegram/telegram.router";
|
||||||
|
import walletsRouter from "./modules/wallets/wallets.routes";
|
||||||
|
|
||||||
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/settings", settingsRouter);
|
||||||
|
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();
|
||||||
|
startBot();
|
||||||
|
|
||||||
logger.info("Backend started");
|
logger.info("Backend started");
|
||||||
|
|
||||||
|
|||||||
14
apps/backend/src/lib/auth-middleware.ts
Normal file
14
apps/backend/src/lib/auth-middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
22
apps/backend/src/lib/auth.ts
Normal file
22
apps/backend/src/lib/auth.ts
Normal 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"],
|
||||||
|
});
|
||||||
42
apps/backend/src/lib/cache.ts
Normal file
42
apps/backend/src/lib/cache.ts
Normal 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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
15
apps/backend/src/lib/request-logger.ts
Normal file
15
apps/backend/src/lib/request-logger.ts
Normal 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`);
|
||||||
|
}
|
||||||
55
apps/backend/src/lib/telegram.ts
Normal file
55
apps/backend/src/lib/telegram.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { generateExpensesForCurrentMonth } from "./expenses.service";
|
||||||
|
|
||||||
|
export function startExpenseJob(): void {
|
||||||
|
cron.schedule("0 1 1 * *", async () => {
|
||||||
|
logger.info("Generating monthly expenses...");
|
||||||
|
try {
|
||||||
|
const results = await generateExpensesForCurrentMonth();
|
||||||
|
logger.info({ results }, "Monthly expenses generated");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to generate monthly expenses");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info("Expense cron scheduled: 0 1 1 * *");
|
||||||
|
}
|
||||||
40
apps/backend/src/modules/expenses/expenses.routes.ts
Normal file
40
apps/backend/src/modules/expenses/expenses.routes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
|
||||||
|
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
|
||||||
|
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
|
||||||
|
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
|
||||||
|
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
|
||||||
|
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
|
||||||
|
import { getTotalPendingHandler } from "./handlers/getTotalPending";
|
||||||
|
import { importExpensesHandler } from "./handlers/importExpenses";
|
||||||
|
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
|
||||||
|
import { listExpensesHandler } from "./handlers/listExpenses";
|
||||||
|
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
|
||||||
|
import { payExpenseHandler } from "./handlers/payExpense";
|
||||||
|
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
|
||||||
|
import { updateExpenseHandler } from "./handlers/updateExpense";
|
||||||
|
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/periodic-expenses", listPeriodicExpensesHandler);
|
||||||
|
app.post("/periodic-expenses", createPeriodicExpenseHandler);
|
||||||
|
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
|
||||||
|
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
|
||||||
|
app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
|
||||||
|
app.post(
|
||||||
|
"/periodic-expenses/:id/generate-month",
|
||||||
|
generateMonthlyExpenseHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
|
||||||
|
app.get("/expenses/totals", getMonthlyTotalsHandler);
|
||||||
|
app.get("/expenses/pending-upcoming", getPendingUpcomingExpensesHandler);
|
||||||
|
app.get("/expenses/pending-total", getTotalPendingHandler);
|
||||||
|
app.get("/expenses", listExpensesHandler);
|
||||||
|
app.post("/expenses", createNonPeriodicExpenseHandler);
|
||||||
|
app.post("/expenses/import", importExpensesHandler);
|
||||||
|
app.put("/expenses/:id/pay", payExpenseHandler);
|
||||||
|
app.put("/expenses/:id", updateExpenseHandler);
|
||||||
|
|
||||||
|
export default app;
|
||||||
590
apps/backend/src/modules/expenses/expenses.service.ts
Normal file
590
apps/backend/src/modules/expenses/expenses.service.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { PaymentStatus, QuoteType, type Prisma } from "../../generated/prisma/client";
|
||||||
|
import { cache } from "../../lib/cache";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
import { roundTo } from "../../lib/utils";
|
||||||
|
|
||||||
|
export const createPeriodicExpenseSchema = z.object({
|
||||||
|
description: z.string().min(1).max(50),
|
||||||
|
defaultDueDay: z.number().int().min(1).max(31),
|
||||||
|
defaultAmount: z.number().positive(),
|
||||||
|
periods: z.array(z.number().int().min(1).max(12)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePeriodicExpenseSchema = createPeriodicExpenseSchema;
|
||||||
|
|
||||||
|
export const createNonPeriodicExpenseSchema = z.object({
|
||||||
|
description: z.string().min(1).max(50),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
dueDate: z.string().datetime(),
|
||||||
|
walletId: z.number().int().positive(),
|
||||||
|
usdcConversionRate: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const payExpenseSchema = z.object({
|
||||||
|
amountPayed: z.number().positive(),
|
||||||
|
paymentDate: z.string().datetime().optional(),
|
||||||
|
walletId: z.number().int().positive(),
|
||||||
|
usdcConversionRate: z.number().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateExpenseSchema = z.object({
|
||||||
|
amount: z.number().positive("El monto debe ser mayor a 0"),
|
||||||
|
dueDate: z
|
||||||
|
.string()
|
||||||
|
.datetime({ message: "La fecha debe ser una fecha ISO válida" }),
|
||||||
|
status: z.enum(["PENDING"]).optional(),
|
||||||
|
amountPayed: z
|
||||||
|
.number()
|
||||||
|
.positive("El monto pagado debe ser mayor a 0")
|
||||||
|
.optional(),
|
||||||
|
paymentDate: z
|
||||||
|
.string()
|
||||||
|
.datetime({ message: "La fecha debe ser una fecha ISO válida" })
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastDayOfMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDueDate(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
dueDay: number,
|
||||||
|
): Date {
|
||||||
|
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
|
||||||
|
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentYearMonthUTC(): { year: number; month: number } {
|
||||||
|
const now = new Date();
|
||||||
|
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getUTCFullYear() === b.getUTCFullYear() &&
|
||||||
|
a.getUTCMonth() === b.getUTCMonth() &&
|
||||||
|
a.getUTCDate() === b.getUTCDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBeloSellPriceForDate(
|
||||||
|
paymentDate: Date,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (isSameDay(paymentDate, now)) {
|
||||||
|
const quote = await prisma.quote.findFirst({
|
||||||
|
where: { type: QuoteType.BELO },
|
||||||
|
});
|
||||||
|
return quote ? Number(quote.sell) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
paymentDate.getUTCFullYear(),
|
||||||
|
paymentDate.getUTCMonth(),
|
||||||
|
paymentDate.getUTCDate(),
|
||||||
|
17, 0, 0, 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = await prisma.quoteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
type: QuoteType.BELO,
|
||||||
|
timeStamp: { lte: target },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await prisma.quoteHistory.findFirst({
|
||||||
|
where: {
|
||||||
|
type: QuoteType.BELO,
|
||||||
|
timeStamp: { gte: target },
|
||||||
|
},
|
||||||
|
orderBy: { timeStamp: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (before && after) {
|
||||||
|
const beforeDiff = Math.abs(before.timeStamp.getTime() - target.getTime());
|
||||||
|
const afterDiff = Math.abs(after.timeStamp.getTime() - target.getTime());
|
||||||
|
return beforeDiff <= afterDiff ? Number(before.sell) : Number(after.sell);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (before) return Number(before.sell);
|
||||||
|
if (after) return Number(after.sell);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPeriodicExpenses() {
|
||||||
|
const cached = cache.get("expenses:periodic");
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await prisma.periodicExpense.findMany({
|
||||||
|
where: { isDeleted: false },
|
||||||
|
orderBy: { description: "asc" },
|
||||||
|
});
|
||||||
|
cache.set("expenses:periodic", data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPeriodicExpense(
|
||||||
|
data: z.infer<typeof createPeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const result = await prisma.periodicExpense.create({ data });
|
||||||
|
const { month } = getCurrentYearMonthUTC();
|
||||||
|
const currentMonthApplicable = data.periods.includes(month);
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return { ...result, currentMonthApplicable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePeriodicExpense(
|
||||||
|
id: number,
|
||||||
|
data: z.infer<typeof updatePeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const result = await prisma.periodicExpense.update({ where: { id }, data });
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function softDeletePeriodicExpense(id: number) {
|
||||||
|
const result = await prisma.periodicExpense.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isDeleted: true },
|
||||||
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMonthlyExpense(id: number) {
|
||||||
|
const periodic = await prisma.periodicExpense.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
|
|
||||||
|
const existing = await prisma.expense.findFirst({
|
||||||
|
where: { periodicExpenseId: id, year, month },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
||||||
|
const result = await prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
description: periodic.description,
|
||||||
|
periodicExpenseId: id,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
amount: periodic.defaultAmount,
|
||||||
|
dueDate,
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listExpenses(params: {
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
periodicExpenseId?: number;
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params;
|
||||||
|
|
||||||
|
const cacheKey = `expenses:list:${status ?? "all"}:${page}:${pageSize}:${periodicExpenseId ?? "none"}:${search ?? ""}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (status === "PENDING" || status === "PAYED") {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
if (periodicExpenseId !== undefined) {
|
||||||
|
where.periodicExpenseId = periodicExpenseId;
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
where.description = { contains: search, mode: "insensitive" };
|
||||||
|
}
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.expense.findMany({
|
||||||
|
where,
|
||||||
|
include: { periodicExpense: true },
|
||||||
|
orderBy: { dueDate: "desc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.expense.count({ where }),
|
||||||
|
]);
|
||||||
|
const result = { data, total, page, pageSize };
|
||||||
|
cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNonPeriodicExpense(
|
||||||
|
data: z.infer<typeof createNonPeriodicExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const dueDate = new Date(data.dueDate);
|
||||||
|
const year = dueDate.getUTCFullYear();
|
||||||
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
|
|
||||||
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error("Billetera no encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUSDC = wallet.currency === "USDC";
|
||||||
|
|
||||||
|
if (isUSDC && !data.usdcConversionRate) {
|
||||||
|
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
|
||||||
|
}
|
||||||
|
|
||||||
|
const belo = isUSDC ? null : await getBeloSellPriceForDate(dueDate);
|
||||||
|
|
||||||
|
const movementAmount =
|
||||||
|
isUSDC && data.usdcConversionRate
|
||||||
|
? -roundTo(data.amount / data.usdcConversionRate, 4)
|
||||||
|
: -data.amount;
|
||||||
|
|
||||||
|
const expenseData: Prisma.ExpenseUncheckedCreateInput = {
|
||||||
|
description: data.description,
|
||||||
|
amount: data.amount,
|
||||||
|
amountPayed: data.amount,
|
||||||
|
dueDate,
|
||||||
|
paymentDate: dueDate,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
status: PaymentStatus.PAYED,
|
||||||
|
walletId: data.walletId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUSDC && data.usdcConversionRate) {
|
||||||
|
expenseData.beloPrice = data.usdcConversionRate;
|
||||||
|
expenseData.usdcEquivalent = roundTo(
|
||||||
|
data.amount / data.usdcConversionRate,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
} else if (belo !== null) {
|
||||||
|
expenseData.beloPrice = belo;
|
||||||
|
expenseData.usdcEquivalent = roundTo(data.amount / belo, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await prisma.$transaction([
|
||||||
|
prisma.expense.create({ data: expenseData }),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
data: { balance: { increment: movementAmount } },
|
||||||
|
}),
|
||||||
|
prisma.walletMovement.create({
|
||||||
|
data: {
|
||||||
|
walletId: data.walletId,
|
||||||
|
type: "WITHDRAWAL",
|
||||||
|
amount: movementAmount,
|
||||||
|
description: `Pago: ${data.description}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payExpense(
|
||||||
|
id: number,
|
||||||
|
data: z.infer<typeof payExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const expense = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
||||||
|
|
||||||
|
const paymentDate = data.paymentDate
|
||||||
|
? new Date(data.paymentDate)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error("Billetera no encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUSDC = wallet.currency === "USDC";
|
||||||
|
|
||||||
|
if (isUSDC && !data.usdcConversionRate) {
|
||||||
|
throw new Error("La tasa de conversión es obligatoria para billeteras USDC");
|
||||||
|
}
|
||||||
|
|
||||||
|
const belo = isUSDC ? null : await getBeloSellPriceForDate(paymentDate);
|
||||||
|
|
||||||
|
const movementAmount = isUSDC && data.usdcConversionRate
|
||||||
|
? -roundTo(data.amountPayed / data.usdcConversionRate, 4)
|
||||||
|
: -data.amountPayed;
|
||||||
|
|
||||||
|
const expenseUpdateData: Prisma.ExpenseUncheckedUpdateInput = {
|
||||||
|
status: PaymentStatus.PAYED,
|
||||||
|
amountPayed: data.amountPayed,
|
||||||
|
paymentDate,
|
||||||
|
walletId: data.walletId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isUSDC && data.usdcConversionRate) {
|
||||||
|
expenseUpdateData.beloPrice = data.usdcConversionRate;
|
||||||
|
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / data.usdcConversionRate, 6);
|
||||||
|
} else if (belo !== null) {
|
||||||
|
expenseUpdateData.beloPrice = belo;
|
||||||
|
expenseUpdateData.usdcEquivalent = roundTo(data.amountPayed / belo, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await prisma.$transaction([
|
||||||
|
prisma.expense.update({ where: { id }, data: expenseUpdateData }),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: data.walletId },
|
||||||
|
data: { balance: { increment: movementAmount } },
|
||||||
|
}),
|
||||||
|
prisma.walletMovement.create({
|
||||||
|
data: {
|
||||||
|
walletId: data.walletId,
|
||||||
|
type: "WITHDRAWAL",
|
||||||
|
amount: movementAmount,
|
||||||
|
description: `Pago: ${expense.description}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpense(
|
||||||
|
id: number,
|
||||||
|
data: z.infer<typeof updateExpenseSchema>,
|
||||||
|
) {
|
||||||
|
const existing = await prisma.expense.findUniqueOrThrow({ where: { id } });
|
||||||
|
|
||||||
|
const dueDate = new Date(data.dueDate);
|
||||||
|
const year = dueDate.getUTCFullYear();
|
||||||
|
const month = dueDate.getUTCMonth() + 1;
|
||||||
|
|
||||||
|
const updateData: Prisma.ExpenseUncheckedUpdateInput = {
|
||||||
|
amount: data.amount,
|
||||||
|
dueDate,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
};
|
||||||
|
|
||||||
|
let walletAdjustment: {
|
||||||
|
walletId: number;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (data.status === "PENDING") {
|
||||||
|
updateData.status = PaymentStatus.PENDING;
|
||||||
|
updateData.amountPayed = null;
|
||||||
|
updateData.paymentDate = null;
|
||||||
|
updateData.beloPrice = null;
|
||||||
|
updateData.usdcEquivalent = null;
|
||||||
|
|
||||||
|
if (existing.status === PaymentStatus.PAYED && existing.walletId) {
|
||||||
|
const refundAmount = existing.amountPayed
|
||||||
|
? Number(existing.amountPayed)
|
||||||
|
: Number(existing.amount);
|
||||||
|
walletAdjustment = {
|
||||||
|
walletId: existing.walletId,
|
||||||
|
amount: refundAmount,
|
||||||
|
description: "Anulación de gasto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (existing.status === PaymentStatus.PAYED) {
|
||||||
|
if (data.amountPayed !== undefined) {
|
||||||
|
updateData.amountPayed = data.amountPayed;
|
||||||
|
}
|
||||||
|
if (data.paymentDate !== undefined) {
|
||||||
|
updateData.paymentDate = new Date(data.paymentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPaymentDate = updateData.paymentDate instanceof Date
|
||||||
|
? updateData.paymentDate
|
||||||
|
: (existing.paymentDate ?? undefined);
|
||||||
|
const amountPayed = updateData.amountPayed ?? existing.amountPayed;
|
||||||
|
if (resolvedPaymentDate && amountPayed !== undefined && amountPayed !== null) {
|
||||||
|
const belo = await getBeloSellPriceForDate(resolvedPaymentDate);
|
||||||
|
if (belo !== null) {
|
||||||
|
updateData.beloPrice = belo;
|
||||||
|
updateData.usdcEquivalent = roundTo(Number(amountPayed) / belo, 6);
|
||||||
|
} else {
|
||||||
|
updateData.beloPrice = null;
|
||||||
|
updateData.usdcEquivalent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.walletId) {
|
||||||
|
const oldPayed = existing.amountPayed
|
||||||
|
? Number(existing.amountPayed)
|
||||||
|
: Number(existing.amount);
|
||||||
|
const newPayed = data.amountPayed !== undefined
|
||||||
|
? Number(data.amountPayed)
|
||||||
|
: Number(data.amount);
|
||||||
|
|
||||||
|
if (newPayed !== oldPayed) {
|
||||||
|
const difference = newPayed - oldPayed;
|
||||||
|
walletAdjustment = {
|
||||||
|
walletId: existing.walletId,
|
||||||
|
amount: -difference,
|
||||||
|
description: "Ajuste por edición de gasto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (walletAdjustment) {
|
||||||
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { id: walletAdjustment.walletId },
|
||||||
|
});
|
||||||
|
if (!wallet) {
|
||||||
|
throw new Error("Billetera no encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await prisma.$transaction([
|
||||||
|
prisma.expense.update({ where: { id }, data: updateData }),
|
||||||
|
prisma.wallet.update({
|
||||||
|
where: { id: walletAdjustment.walletId },
|
||||||
|
data: { balance: { increment: walletAdjustment.amount } },
|
||||||
|
}),
|
||||||
|
prisma.walletMovement.create({
|
||||||
|
data: {
|
||||||
|
walletId: walletAdjustment.walletId,
|
||||||
|
type: walletAdjustment.amount > 0 ? "DEPOSIT" : "WITHDRAWAL",
|
||||||
|
amount: walletAdjustment.amount,
|
||||||
|
description: walletAdjustment.description,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.expense.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyPayedTotal(year: number, month: number) {
|
||||||
|
const cacheKey = `expenses:monthly-total:${year}:${month}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
||||||
|
SELECT CAST(SUM("amountPayed") AS NUMERIC) as total
|
||||||
|
FROM expenses
|
||||||
|
WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus"
|
||||||
|
`;
|
||||||
|
const data = { total: Number(result[0]?.total ?? 0) };
|
||||||
|
cache.set(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyTotals(year: number, month: number) {
|
||||||
|
const cacheKey = `expenses:totals:${year}:${month}`;
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await prisma.$queryRaw<
|
||||||
|
Array<{ payed: string | null; pending: string | null }>
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
(SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed,
|
||||||
|
(SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
|
||||||
|
`;
|
||||||
|
const data = {
|
||||||
|
payed: Number(result[0]?.payed ?? 0),
|
||||||
|
pending: Number(result[0]?.pending ?? 0),
|
||||||
|
};
|
||||||
|
cache.set(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTotalPending() {
|
||||||
|
const cached = cache.get("expenses:pending-total");
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const result = await prisma.$queryRaw<Array<{ total: string | null }>>`
|
||||||
|
SELECT CAST(SUM(amount) AS NUMERIC) as total
|
||||||
|
FROM expenses
|
||||||
|
WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus"
|
||||||
|
`;
|
||||||
|
const data = { total: Number(result[0]?.total ?? 0) };
|
||||||
|
cache.set("expenses:pending-total", data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingUpcomingExpenses() {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(
|
||||||
|
Date.UTC(
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
now.getUTCMonth(),
|
||||||
|
now.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fiveDaysLater = new Date(today);
|
||||||
|
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
|
||||||
|
|
||||||
|
const data = await prisma.expense.findMany({
|
||||||
|
where: {
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
dueDate: { lte: fiveDaysLater },
|
||||||
|
},
|
||||||
|
include: { periodicExpense: true },
|
||||||
|
orderBy: { dueDate: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.map((expense) => ({
|
||||||
|
...expense,
|
||||||
|
dueType:
|
||||||
|
expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateExpensesForCurrentMonth() {
|
||||||
|
const { year, month } = getCurrentYearMonthUTC();
|
||||||
|
|
||||||
|
const periodics = await prisma.periodicExpense.findMany({
|
||||||
|
where: { isDeleted: false, periods: { has: month } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results: Array<{ id: number; status: string }> = [];
|
||||||
|
for (const periodic of periodics) {
|
||||||
|
const existing = await prisma.expense.findFirst({
|
||||||
|
where: { periodicExpenseId: periodic.id, year, month },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
results.push({ id: periodic.id, status: "already_exists" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
|
||||||
|
await prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
description: periodic.description,
|
||||||
|
periodicExpenseId: periodic.id,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
amount: periodic.defaultAmount,
|
||||||
|
dueDate,
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
results.push({ id: periodic.id, status: "created" });
|
||||||
|
}
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
createNonPeriodicExpense,
|
||||||
|
createNonPeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
|
export async function createNonPeriodicExpenseHandler(c: Context) {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = createNonPeriodicExpenseSchema.parse(body);
|
||||||
|
const result = await createNonPeriodicExpense(parsed);
|
||||||
|
return c.json(result, 201);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
createPeriodicExpense,
|
||||||
|
createPeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
|
export async function createPeriodicExpenseHandler(c: Context) {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = createPeriodicExpenseSchema.parse(body);
|
||||||
|
const result = await createPeriodicExpense(parsed);
|
||||||
|
return c.json(result, 201);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { generateMonthlyExpense } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function generateMonthlyExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const result = await generateMonthlyExpense(id);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { getMonthlyPayedTotal } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function getMonthlyPayedTotalHandler(c: Context) {
|
||||||
|
const year = parseInt(
|
||||||
|
c.req.query("year") ?? String(new Date().getUTCFullYear()),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const month = parseInt(
|
||||||
|
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const result = await getMonthlyPayedTotal(year, month);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { getMonthlyTotals } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function getMonthlyTotalsHandler(c: Context) {
|
||||||
|
const year = parseInt(
|
||||||
|
c.req.query("year") ?? String(new Date().getUTCFullYear()),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const month = parseInt(
|
||||||
|
c.req.query("month") ?? String(new Date().getUTCMonth() + 1),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const result = await getMonthlyTotals(year, month);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { getTotalPending } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function getTotalPendingHandler(c: Context) {
|
||||||
|
const result = await getTotalPending();
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
132
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
132
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { PaymentStatus } from "../../../generated/prisma/client";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
|
function parseAmount(raw: string): number | null {
|
||||||
|
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const n = Number(cleaned);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(raw: string): Date | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const d = new Date(trimmed);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importExpensesHandler(c: Context) {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body.file;
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ error: "No se envió ningún archivo" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "El archivo está vacío o solo tiene encabezados" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const parts = line.split("|");
|
||||||
|
if (parts.length < 10) {
|
||||||
|
errors.push(
|
||||||
|
`Línea ${i + 1}: formato inválido (${parts.length} columnas)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = parts[9].trim();
|
||||||
|
const year = Number(parts[2].trim());
|
||||||
|
const month = Number(parts[3].trim());
|
||||||
|
const rawAmount = parts[4].trim();
|
||||||
|
const rawAmountPayed = parts[5].trim();
|
||||||
|
const rawStatus = parts[6].trim();
|
||||||
|
const rawDueDate = parts[7].trim();
|
||||||
|
const rawPaymentDate = parts[8].trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const amount = parseAmount(rawAmount);
|
||||||
|
if (amount === null) {
|
||||||
|
errors.push(`Línea ${i + 1} ("${description}"): monto inválido`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountPayed = parseAmount(rawAmountPayed);
|
||||||
|
const dueDate = parseDate(rawDueDate);
|
||||||
|
const paymentDate = parseDate(rawPaymentDate);
|
||||||
|
|
||||||
|
if (!dueDate) {
|
||||||
|
errors.push(
|
||||||
|
`Línea ${i + 1} ("${description}"): fecha de vencimiento inválida`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.expense.findFirst({
|
||||||
|
where: { description, year, month },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let periodicExpenseId: number | null = null;
|
||||||
|
if (parts[1].trim()) {
|
||||||
|
const periodic = await prisma.periodicExpense.findFirst({
|
||||||
|
where: { description: { equals: description, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
if (periodic) {
|
||||||
|
periodicExpenseId = periodic.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
rawStatus === "PAYED" ? PaymentStatus.PAYED : PaymentStatus.PENDING;
|
||||||
|
|
||||||
|
await prisma.expense.create({
|
||||||
|
data: {
|
||||||
|
description,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
amount,
|
||||||
|
amountPayed,
|
||||||
|
status,
|
||||||
|
dueDate,
|
||||||
|
paymentDate,
|
||||||
|
periodicExpenseId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
imported++;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
|
||||||
|
logger.error({ err, description }, "Error importing expense");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { cache } from "../../../lib/cache";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { prisma } from "../../../lib/prisma";
|
||||||
|
|
||||||
|
function parseArgentineAmount(raw: string): number {
|
||||||
|
const cleaned = raw.replace("$", "").replace(/,/g, "").trim();
|
||||||
|
return Number(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePeriods(raw: string): number[] {
|
||||||
|
const inner = raw.replace(/^\{|\}$/g, "").trim();
|
||||||
|
if (!inner) return [];
|
||||||
|
return inner.split(",").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importPeriodicExpensesHandler(c: Context) {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body.file;
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ error: "No se envió ningún archivo" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
return c.json(
|
||||||
|
{ error: "El archivo está vacío o solo tiene encabezados" },
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let skippedNoPeriods = 0;
|
||||||
|
let skippedDuplicate = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const parts = line.split("|");
|
||||||
|
if (parts.length < 5) {
|
||||||
|
errors.push(`Línea ${i + 1}: formato inválido`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = parts[1].trim();
|
||||||
|
const defaultDueDay = Number(parts[2].trim());
|
||||||
|
const rawAmount = parts[3].trim();
|
||||||
|
const rawPeriods = parts[4].trim();
|
||||||
|
|
||||||
|
const periods = parsePeriods(rawPeriods);
|
||||||
|
|
||||||
|
if (periods.length === 0) {
|
||||||
|
skippedNoPeriods++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await prisma.periodicExpense.findFirst({
|
||||||
|
where: { description: { equals: description, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skippedDuplicate++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAmount = parseArgentineAmount(rawAmount);
|
||||||
|
|
||||||
|
await prisma.periodicExpense.create({
|
||||||
|
data: {
|
||||||
|
description,
|
||||||
|
defaultDueDay: Number.isNaN(defaultDueDay) ? 1 : defaultDueDay,
|
||||||
|
defaultAmount: Number.isNaN(defaultAmount) ? 0 : defaultAmount,
|
||||||
|
periods,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
imported++;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
errors.push(`Línea ${i + 1} ("${description}"): ${message}`);
|
||||||
|
logger.error({ err, description }, "Error importing periodic expense");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("expenses:");
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
imported,
|
||||||
|
skipped: skippedNoPeriods + skippedDuplicate,
|
||||||
|
skippedNoPeriods,
|
||||||
|
skippedDuplicate,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
20
apps/backend/src/modules/expenses/handlers/listExpenses.ts
Normal file
20
apps/backend/src/modules/expenses/handlers/listExpenses.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { listExpenses } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function listExpensesHandler(c: Context) {
|
||||||
|
const status = c.req.query("status");
|
||||||
|
const page = parseInt(c.req.query("page") ?? "1", 10);
|
||||||
|
const pageSize = parseInt(c.req.query("pageSize") ?? "10", 10);
|
||||||
|
const periodicExpenseId = c.req.query("periodicExpenseId");
|
||||||
|
const search = c.req.query("search");
|
||||||
|
const result = await listExpenses({
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
periodicExpenseId: periodicExpenseId
|
||||||
|
? parseInt(periodicExpenseId, 10)
|
||||||
|
: undefined,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { listPeriodicExpenses } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function listPeriodicExpensesHandler(c: Context) {
|
||||||
|
const expenses = await listPeriodicExpenses();
|
||||||
|
return c.json(expenses);
|
||||||
|
}
|
||||||
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { payExpense, payExpenseSchema } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function payExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = payExpenseSchema.parse(body);
|
||||||
|
const result = await payExpense(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { softDeletePeriodicExpense } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function softDeletePeriodicExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
await softDeletePeriodicExpense(id);
|
||||||
|
return c.json({ success: true });
|
||||||
|
}
|
||||||
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/updateExpense.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
updatePeriodicExpense,
|
||||||
|
updatePeriodicExpenseSchema,
|
||||||
|
} from "../expenses.service";
|
||||||
|
|
||||||
|
export async function updatePeriodicExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = updatePeriodicExpenseSchema.parse(body);
|
||||||
|
const result = await updatePeriodicExpense(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
29
apps/backend/src/modules/settings/settings.handler.ts
Normal file
29
apps/backend/src/modules/settings/settings.handler.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { cache } from "../../lib/cache";
|
||||||
|
import { prisma } from "../../lib/prisma";
|
||||||
|
|
||||||
|
export async function getSettings(c: Context) {
|
||||||
|
const cached = cache.get("settings:all");
|
||||||
|
if (cached) return c.json(cached);
|
||||||
|
|
||||||
|
const settings = await prisma.setting.findMany();
|
||||||
|
cache.set("settings:all", settings, 60_000);
|
||||||
|
return c.json(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(c: Context) {
|
||||||
|
const body = await c.req.json<{ key: string; value: string }>();
|
||||||
|
|
||||||
|
if (!body.key || body.value === undefined) {
|
||||||
|
return c.json({ error: "key and value are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setting = await prisma.setting.upsert({
|
||||||
|
where: { key: body.key },
|
||||||
|
update: { value: body.value },
|
||||||
|
create: { key: body.key, value: body.value },
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.invalidateByPrefix("settings:");
|
||||||
|
return c.json(setting);
|
||||||
|
}
|
||||||
9
apps/backend/src/modules/settings/settings.routes.ts
Normal file
9
apps/backend/src/modules/settings/settings.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { getSettings, updateSetting } from "./settings.handler";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", getSettings);
|
||||||
|
app.put("/", updateSetting);
|
||||||
|
|
||||||
|
export default app;
|
||||||
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal file
20
apps/backend/src/modules/telegram/telegram.handler.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal file
9
apps/backend/src/modules/telegram/telegram.router.ts
Normal 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;
|
||||||
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal file
151
apps/backend/src/modules/telegram/telegram.service.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
adjustBalance,
|
||||||
|
adjustBalanceSchema,
|
||||||
|
} from "../wallets.service";
|
||||||
|
|
||||||
|
export async function adjustBalanceHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = adjustBalanceSchema.parse(body);
|
||||||
|
const result = await adjustBalance(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
createWallet,
|
||||||
|
createWalletSchema,
|
||||||
|
} from "../wallets.service";
|
||||||
|
|
||||||
|
export async function createWalletHandler(c: Context) {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = createWalletSchema.parse(body);
|
||||||
|
const result = await createWallet(parsed);
|
||||||
|
return c.json(result, 201);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { deleteWallet } from "../wallets.service";
|
||||||
|
|
||||||
|
export async function deleteWalletHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
await deleteWallet(id);
|
||||||
|
return c.json({ success: true });
|
||||||
|
}
|
||||||
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
depositWallet,
|
||||||
|
depositSchema,
|
||||||
|
} from "../wallets.service";
|
||||||
|
|
||||||
|
export async function depositWalletHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = depositSchema.parse(body);
|
||||||
|
const result = await depositWallet(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { listMovements } from "../wallets.service";
|
||||||
|
|
||||||
|
export async function listMovementsHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const result = await listMovements(id);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { listWallets } from "../wallets.service";
|
||||||
|
|
||||||
|
export async function listWalletsHandler(c: Context) {
|
||||||
|
const result = await listWallets();
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
transferSchema,
|
||||||
|
transferWallet,
|
||||||
|
} from "../wallets.service";
|
||||||
|
|
||||||
|
export async function transferWalletHandler(c: Context) {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = transferSchema.parse(body);
|
||||||
|
const result = await transferWallet(parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import {
|
||||||
|
updateWallet,
|
||||||
|
updateWalletSchema,
|
||||||
|
} from "../wallets.service";
|
||||||
|
|
||||||
|
export async function updateWalletHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = updateWalletSchema.parse(body);
|
||||||
|
const result = await updateWallet(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
26
apps/backend/src/modules/wallets/wallets.routes.ts
Normal file
26
apps/backend/src/modules/wallets/wallets.routes.ts
Normal 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;
|
||||||
243
apps/backend/src/modules/wallets/wallets.service.ts
Normal file
243
apps/backend/src/modules/wallets/wallets.service.ts
Normal 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;
|
||||||
|
}
|
||||||
1430
apps/backend/tests/expenses.test.ts
Normal file
1430
apps/backend/tests/expenses.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
8362
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
8362
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
File diff suppressed because it is too large
Load Diff
130
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
130
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,130 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "5f00aae5",
|
|
||||||
"configHash": "c0c89d92",
|
|
||||||
"lockfileHash": "9500aa13",
|
|
||||||
"browserHash": "efa9b066",
|
|
||||||
"optimized": {
|
|
||||||
"react": {
|
|
||||||
"src": "../../../../../node_modules/react/index.js",
|
|
||||||
"file": "react.js",
|
|
||||||
"fileHash": "98e991e0",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"src": "../../../../../node_modules/react-dom/index.js",
|
|
||||||
"file": "react-dom.js",
|
|
||||||
"fileHash": "3b7ba340",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"react/jsx-dev-runtime": {
|
|
||||||
"src": "../../../../../node_modules/react/jsx-dev-runtime.js",
|
|
||||||
"file": "react_jsx-dev-runtime.js",
|
|
||||||
"fileHash": "8b662de2",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"react/jsx-runtime": {
|
|
||||||
"src": "../../../../../node_modules/react/jsx-runtime.js",
|
|
||||||
"file": "react_jsx-runtime.js",
|
|
||||||
"fileHash": "edbd65cc",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"@tanstack/react-query": {
|
|
||||||
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
|
|
||||||
"file": "@tanstack_react-query.js",
|
|
||||||
"fileHash": "705b896d",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"@tanstack/react-query-devtools": {
|
|
||||||
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
|
|
||||||
"file": "@tanstack_react-query-devtools.js",
|
|
||||||
"fileHash": "70485a02",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"@tanstack/react-router": {
|
|
||||||
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
|
|
||||||
"file": "@tanstack_react-router.js",
|
|
||||||
"fileHash": "141b1c82",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"class-variance-authority": {
|
|
||||||
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
|
|
||||||
"file": "class-variance-authority.js",
|
|
||||||
"fileHash": "6c7abe67",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"clsx": {
|
|
||||||
"src": "../../../../../node_modules/clsx/dist/clsx.mjs",
|
|
||||||
"file": "clsx.js",
|
|
||||||
"fileHash": "97c8d984",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"lucide-react": {
|
|
||||||
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
|
|
||||||
"file": "lucide-react.js",
|
|
||||||
"fileHash": "ad567fd3",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"radix-ui": {
|
|
||||||
"src": "../../../../../node_modules/radix-ui/dist/index.mjs",
|
|
||||||
"file": "radix-ui.js",
|
|
||||||
"fileHash": "10864bf0",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"react-dom/client": {
|
|
||||||
"src": "../../../../../node_modules/react-dom/client.js",
|
|
||||||
"file": "react-dom_client.js",
|
|
||||||
"fileHash": "ab77e82f",
|
|
||||||
"needsInterop": true
|
|
||||||
},
|
|
||||||
"recharts": {
|
|
||||||
"src": "../../../../../node_modules/recharts/es6/index.js",
|
|
||||||
"file": "recharts.js",
|
|
||||||
"fileHash": "d0cc5258",
|
|
||||||
"needsInterop": false
|
|
||||||
},
|
|
||||||
"tailwind-merge": {
|
|
||||||
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
|
|
||||||
"file": "tailwind-merge.js",
|
|
||||||
"fileHash": "f48a862a",
|
|
||||||
"needsInterop": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"chunks": {
|
|
||||||
"chunk-NMJBVCD2": {
|
|
||||||
"file": "chunk-NMJBVCD2.js"
|
|
||||||
},
|
|
||||||
"chunk-X37QSMNJ": {
|
|
||||||
"file": "chunk-X37QSMNJ.js"
|
|
||||||
},
|
|
||||||
"VKXKX7EQ-FAVAKZBN": {
|
|
||||||
"file": "VKXKX7EQ-FAVAKZBN.js"
|
|
||||||
},
|
|
||||||
"ZUJJ2RGI-GFZTQM7R": {
|
|
||||||
"file": "ZUJJ2RGI-GFZTQM7R.js"
|
|
||||||
},
|
|
||||||
"chunk-LSVH5W4R": {
|
|
||||||
"file": "chunk-LSVH5W4R.js"
|
|
||||||
},
|
|
||||||
"chunk-JCMTJHXH": {
|
|
||||||
"file": "chunk-JCMTJHXH.js"
|
|
||||||
},
|
|
||||||
"chunk-TLZOGT6B": {
|
|
||||||
"file": "chunk-TLZOGT6B.js"
|
|
||||||
},
|
|
||||||
"chunk-A2TD7EYA": {
|
|
||||||
"file": "chunk-A2TD7EYA.js"
|
|
||||||
},
|
|
||||||
"chunk-SOQCU2R6": {
|
|
||||||
"file": "chunk-SOQCU2R6.js"
|
|
||||||
},
|
|
||||||
"chunk-6VO5C3OI": {
|
|
||||||
"file": "chunk-6VO5C3OI.js"
|
|
||||||
},
|
|
||||||
"chunk-WFNHCR67": {
|
|
||||||
"file": "chunk-WFNHCR67.js"
|
|
||||||
},
|
|
||||||
"chunk-WOOG5QLI": {
|
|
||||||
"file": "chunk-WOOG5QLI.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15782
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
15782
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
7
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
File diff suppressed because one or more lines are too long
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
@@ -2,15 +2,15 @@ import {
|
|||||||
require_with_selector
|
require_with_selector
|
||||||
} from "./chunk-NMJBVCD2.js";
|
} from "./chunk-NMJBVCD2.js";
|
||||||
import "./chunk-X37QSMNJ.js";
|
import "./chunk-X37QSMNJ.js";
|
||||||
|
import {
|
||||||
|
clsx
|
||||||
|
} from "./chunk-WFNHCR67.js";
|
||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-JCMTJHXH.js";
|
} from "./chunk-JCMTJHXH.js";
|
||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-6VO5C3OI.js";
|
} from "./chunk-6VO5C3OI.js";
|
||||||
import {
|
|
||||||
clsx
|
|
||||||
} from "./chunk-WFNHCR67.js";
|
|
||||||
import {
|
import {
|
||||||
__commonJS,
|
__commonJS,
|
||||||
__export,
|
__export,
|
||||||
|
|||||||
@@ -6,24 +6,33 @@
|
|||||||
"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",
|
||||||
"@personal-admin/common": "workspace:*",
|
"@personal-admin/common": "workspace:*",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"@tanstack/react-query-devtools": "^5.100.11",
|
"@tanstack/react-query-devtools": "^5.100.11",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.3.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.76.1",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/react-router": "^1.170.4",
|
"@tanstack/react-router": "^1.170.4",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
98
apps/frontend/src/components/ui/calendar.tsx
Normal file
98
apps/frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
classNames={{
|
||||||
|
root: "w-full",
|
||||||
|
months: "flex flex-col",
|
||||||
|
month: "flex flex-col gap-3",
|
||||||
|
month_caption: "flex items-center justify-center",
|
||||||
|
caption_label: "hidden",
|
||||||
|
nav: "flex items-center gap-1",
|
||||||
|
button_previous:
|
||||||
|
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
|
||||||
|
button_next:
|
||||||
|
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
|
||||||
|
dropdowns: "flex items-center gap-2",
|
||||||
|
dropdown_root: "relative inline-flex items-center",
|
||||||
|
dropdown:
|
||||||
|
"h-7 w-full appearance-none rounded-md border border-input bg-background pl-2 pr-6 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer",
|
||||||
|
months_dropdown: "w-28",
|
||||||
|
years_dropdown: "w-22",
|
||||||
|
chevron: "size-3 text-muted-foreground",
|
||||||
|
month_grid: "w-full border-collapse",
|
||||||
|
weekdays: "flex",
|
||||||
|
weekday:
|
||||||
|
"w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
|
||||||
|
week: "flex w-full",
|
||||||
|
day: "p-0 size-8 text-center text-sm",
|
||||||
|
day_button:
|
||||||
|
"inline-flex items-center justify-center rounded-md size-8 text-sm font-normal text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-selected:opacity-100",
|
||||||
|
today: "bg-accent text-accent-foreground rounded-md",
|
||||||
|
outside: "text-muted-foreground opacity-50",
|
||||||
|
disabled: "text-muted-foreground opacity-50",
|
||||||
|
range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground rounded-none",
|
||||||
|
range_start:
|
||||||
|
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-l-md",
|
||||||
|
range_end:
|
||||||
|
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-r-md",
|
||||||
|
selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-md",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Chevron: (props) => {
|
||||||
|
const { orientation, ...rest } = props;
|
||||||
|
if (orientation === "up" || orientation === "down") {
|
||||||
|
return <ChevronDownIcon className="size-3" {...rest} />;
|
||||||
|
}
|
||||||
|
const Icon =
|
||||||
|
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
|
||||||
|
return <Icon className="size-4" {...rest} />;
|
||||||
|
},
|
||||||
|
Select: (props) => {
|
||||||
|
const { className, children, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")`,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
61
apps/frontend/src/components/ui/date-picker.tsx
Normal file
61
apps/frontend/src/components/ui/date-picker.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { es } from "date-fns/locale";
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DatePickerProps {
|
||||||
|
value: Date | undefined;
|
||||||
|
onChange: (date: Date | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "Seleccionar fecha",
|
||||||
|
}: DatePickerProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full justify-start gap-2 px-2.5 font-normal",
|
||||||
|
!value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="size-4 shrink-0" />
|
||||||
|
{value ? (
|
||||||
|
format(value, "PPP", { locale: es })
|
||||||
|
) : (
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={value}
|
||||||
|
defaultMonth={value}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
onSelect={(date) => {
|
||||||
|
onChange(date);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/frontend/src/components/ui/input.tsx
Normal file
19
apps/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
130
apps/frontend/src/components/ui/pagination.tsx
Normal file
130
apps/frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pl-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pr-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
};
|
||||||
48
apps/frontend/src/components/ui/popover.tsx
Normal file
48
apps/frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||||
|
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
"data-[side=bottom]:slide-in-from-top-2",
|
||||||
|
"data-[side=left]:slide-in-from-right-2",
|
||||||
|
"data-[side=right]:slide-in-from-left-2",
|
||||||
|
"data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverContent, PopoverTrigger };
|
||||||
242
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal file
242
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MEDIA_QUERY = "(min-width: 640px)";
|
||||||
|
|
||||||
|
function useMediaQuery(query: string) {
|
||||||
|
const [matches, setMatches] = React.useState(() => {
|
||||||
|
if (typeof window === "undefined") return true;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponsiveDialogContextValue {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isDesktop: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResponsiveDialogContext =
|
||||||
|
React.createContext<ResponsiveDialogContextValue | null>(null);
|
||||||
|
|
||||||
|
function useResponsiveDialog() {
|
||||||
|
const ctx = React.useContext(ResponsiveDialogContext);
|
||||||
|
if (!ctx)
|
||||||
|
throw new Error(
|
||||||
|
"ResponsiveDialog components must be used within ResponsiveDialog",
|
||||||
|
);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||||
|
const isDesktop = useMediaQuery(MEDIA_QUERY);
|
||||||
|
|
||||||
|
const controlled = open !== undefined;
|
||||||
|
const currentOpen = controlled ? open : internalOpen;
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: controlled ensures it's defined
|
||||||
|
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveDialogContext
|
||||||
|
value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}
|
||||||
|
>
|
||||||
|
{isDesktop ? (
|
||||||
|
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
) : (
|
||||||
|
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
)}
|
||||||
|
</ResponsiveDialogContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogTrigger({
|
||||||
|
children,
|
||||||
|
asChild,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
const { isDesktop, open, onOpenChange } = useResponsiveDialog();
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="responsive-dialog-overlay"
|
||||||
|
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
|
||||||
|
/>
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="responsive-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
|
||||||
|
"flex flex-col gap-4 rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
|
||||||
|
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
|
||||||
|
"duration-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:hidden"
|
||||||
|
data-slot="responsive-dialog-mobile"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs cursor-default"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
|
||||||
|
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="responsive-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="responsive-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="responsive-dialog-title"
|
||||||
|
className={cn("text-base font-medium text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="responsive-dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResponsiveDialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ResponsiveDialog,
|
||||||
|
ResponsiveDialogClose,
|
||||||
|
ResponsiveDialogContent,
|
||||||
|
ResponsiveDialogDescription,
|
||||||
|
ResponsiveDialogFooter,
|
||||||
|
ResponsiveDialogHeader,
|
||||||
|
ResponsiveDialogTitle,
|
||||||
|
ResponsiveDialogTrigger,
|
||||||
|
};
|
||||||
192
apps/frontend/src/components/ui/select.tsx
Normal file
192
apps/frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
407
apps/frontend/src/features/analysis/AnalysisPage.tsx
Normal file
407
apps/frontend/src/features/analysis/AnalysisPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
|
||||||
import { RelativeTime } from "@/lib/time";
|
|
||||||
import type { Quote } from "@/lib/api";
|
|
||||||
import { useQuotes, useFetchQuotes } from "@/lib/queries";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { 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,
|
||||||
|
useMonthlyTotals,
|
||||||
|
useQuotes,
|
||||||
|
useSettings,
|
||||||
|
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,9 +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 { data: monthlyTotals } = useMonthlyTotals(
|
||||||
|
now.getFullYear(),
|
||||||
|
now.getMonth() + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: settings } = useSettings();
|
||||||
|
const salaryMultiplier =
|
||||||
|
settings?.find((s) => s.key === "salary_multiplier")?.value ?? "4500";
|
||||||
|
|
||||||
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">
|
||||||
@@ -113,22 +159,66 @@ 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">Sueldo</p>
|
||||||
<p className="text-2xl font-bold">$0.00</p>
|
<p className="text-2xl font-bold">
|
||||||
|
$
|
||||||
|
{priceFormatter.format(
|
||||||
|
(belo ? Number(belo.buy) : 0) * Number(salaryMultiplier),
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user