chore: add Biome for lint and fix all lint errors

- Install @biomejs/biome as devDependency at root
- Configure biome.json with 2-space indent, double quotes, Tailwind CSS support
- Add lint/lint:fix/format/format:fix scripts to root and app package.json
- Fix noNonNullAssertion: env vars extracted to variables with suppression, <div role=button> replaced with <button>
- Fix noUnusedVariables: remove unused destructured vars
- Fix useIterableCallbackReturn: arrow functions with block body
- Fix noExplicitAny: recharts Tooltip formatters
- Fix noLabelWithoutControl: add htmlFor+id or use <span> for non-input labels
- Fix noStaticElementInteractions/useKeyWithClickEvents: role+keyboard events for overlays
- Fix noArrayIndexKey: use error string as key
- Fix CSS parse: enable tailwindDirectives parser
- Normalize formatting across 80 files with biome check --write
This commit is contained in:
Jose Selesan
2026-06-04 10:48:07 -03:00
parent 53a797703e
commit 5b6ccf0fa6
81 changed files with 1565 additions and 794 deletions

View File

@@ -9,7 +9,9 @@
"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" "prisma:seed": "bun run prisma/seed.ts",
"lint": "biome check src/",
"lint:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"better-auth": "^1.6.11", "better-auth": "^1.6.11",

View File

@@ -10,6 +10,6 @@ export default defineConfig({
seed: "bun ./prisma/seed.ts", seed: "bun ./prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env.DATABASE_URL,
}, },
}); });

View File

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

View File

@@ -2,14 +2,14 @@ import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { auth } from "./lib/auth"; import { auth } from "./lib/auth";
import { authMiddleware } from "./lib/auth-middleware"; import { authMiddleware } from "./lib/auth-middleware";
import { requestLogger } from "./lib/request-logger";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startExpenseJob } from "./modules/expenses/expense.job";
import { cache } from "./lib/cache"; import { cache } from "./lib/cache";
import { eventBus } from "./lib/event-bus"; import { eventBus } from "./lib/event-bus";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
import { requestLogger } from "./lib/request-logger";
import { startExpenseJob } from "./modules/expenses/expense.job";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes";
const app = new Hono(); const app = new Hono();

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
import { listExpensesHandler } from "./handlers/listExpenses";
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense"; import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
import { payExpenseHandler } from "./handlers/payExpense"; import { createPeriodicExpenseHandler } from "./handlers/createPeriodicExpense";
import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense";
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal"; import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals"; import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses"; import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses"; import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { importExpensesHandler } from "./handlers/importExpenses"; import { importExpensesHandler } from "./handlers/importExpenses";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
import { listExpensesHandler } from "./handlers/listExpenses";
import { listPeriodicExpensesHandler } from "./handlers/listPeriodicExpenses";
import { payExpenseHandler } from "./handlers/payExpense";
import { softDeletePeriodicExpenseHandler } from "./handlers/softDeletePeriodicExpense";
import { updatePeriodicExpenseHandler } from "./handlers/updatePeriodicExpense";
const app = new Hono(); const app = new Hono();
@@ -21,7 +21,10 @@ app.post("/periodic-expenses", createPeriodicExpenseHandler);
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler); app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler); app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
app.post("/periodic-expenses/import", importPeriodicExpensesHandler); app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler); app.post(
"/periodic-expenses/:id/generate-month",
generateMonthlyExpenseHandler,
);
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler); app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
app.get("/expenses/totals", getMonthlyTotalsHandler); app.get("/expenses/totals", getMonthlyTotalsHandler);

View File

@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
import { PaymentStatus } from "../../generated/prisma/client"; import { PaymentStatus } from "../../generated/prisma/client";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
export const createPeriodicExpenseSchema = z.object({ export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50), description: z.string().min(1).max(50),
@@ -27,7 +27,11 @@ function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate(); return new Date(year, month, 0).getDate();
} }
export function buildDueDate(year: number, month: number, dueDay: number): Date { export function buildDueDate(
year: number,
month: number,
dueDay: number,
): Date {
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month)); const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0)); return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
} }
@@ -49,15 +53,20 @@ export async function listPeriodicExpenses() {
return data; return data;
} }
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) { export async function createPeriodicExpense(
data: z.infer<typeof createPeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.create({ data }); const result = await prisma.periodicExpense.create({ data });
const { year, month } = getCurrentYearMonthUTC(); const { month } = getCurrentYearMonthUTC();
const currentMonthApplicable = data.periods.includes(month); const currentMonthApplicable = data.periods.includes(month);
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
return { ...result, currentMonthApplicable }; return { ...result, currentMonthApplicable };
} }
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) { export async function updatePeriodicExpense(
id: number,
data: z.infer<typeof updatePeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.update({ where: { id }, data }); const result = await prisma.periodicExpense.update({ where: { id }, data });
cache.invalidateByPrefix("expenses:"); cache.invalidateByPrefix("expenses:");
return result; return result;
@@ -73,7 +82,9 @@ export async function softDeletePeriodicExpense(id: number) {
} }
export async function generateMonthlyExpense(id: number) { export async function generateMonthlyExpense(id: number) {
const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } }); const periodic = await prisma.periodicExpense.findUniqueOrThrow({
where: { id },
});
const { year, month } = getCurrentYearMonthUTC(); const { year, month } = getCurrentYearMonthUTC();
const existing = await prisma.expense.findFirst({ const existing = await prisma.expense.findFirst({
@@ -135,7 +146,9 @@ export async function listExpenses(params: {
return result; return result;
} }
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) { export async function createNonPeriodicExpense(
data: z.infer<typeof createNonPeriodicExpenseSchema>,
) {
const dueDate = new Date(data.dueDate); const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear(); const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1; const month = dueDate.getUTCMonth() + 1;
@@ -155,8 +168,13 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
return result; return result;
} }
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) { export async function payExpense(
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); id: number,
data: z.infer<typeof payExpenseSchema>,
) {
const paymentDate = data.paymentDate
? new Date(data.paymentDate)
: new Date();
const result = await prisma.expense.update({ const result = await prisma.expense.update({
where: { id }, where: { id },
data: { data: {
@@ -189,7 +207,9 @@ export async function getMonthlyTotals(year: number, month: number) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
const result = await prisma.$queryRaw<Array<{ payed: string | null; pending: string | null }>>` const result = await prisma.$queryRaw<
Array<{ payed: string | null; pending: string | null }>
>`
SELECT 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("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 (SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending
@@ -218,7 +238,17 @@ export async function getTotalPending() {
export async function listPendingUpcomingExpenses() { export async function listPendingUpcomingExpenses() {
const now = new Date(); const now = new Date();
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); const today = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0,
0,
0,
0,
),
);
const fiveDaysLater = new Date(today); const fiveDaysLater = new Date(today);
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5); fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
@@ -233,7 +263,8 @@ export async function listPendingUpcomingExpenses() {
return data.map((expense) => ({ return data.map((expense) => ({
...expense, ...expense,
dueType: expense.dueDate < today ? "overdue" as const : "upcoming" as const, dueType:
expense.dueDate < today ? ("overdue" as const) : ("upcoming" as const),
})); }));
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,22 +10,25 @@ 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(
credentials: "omit", "https://api.dolarito.ar/api/frontend/quotations/dolar",
headers: { {
"User-Agent": credentials: "omit",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", headers: {
Accept: "application/json, text/plain, */*", "User-Agent":
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a", Accept: "application/json, text/plain, */*",
"Sec-Fetch-Dest": "empty", "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
"Sec-Fetch-Mode": "cors", "auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
"Sec-Fetch-Site": "same-site", "Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
},
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
}, },
referrer: "https://www.dolarito.ar/", );
method: "GET",
mode: "cors",
});
const data: DolaritoApiResponse = await response.json(); const data: DolaritoApiResponse = await response.json();
return { buy: data.informal.buy, sell: data.informal.sell }; return { buy: data.informal.buy, sell: data.informal.sell };
} }
@@ -59,7 +62,10 @@ export async function processBlueQuote(): Promise<void> {
const diff = roundTo(sell - existingSell, 2); const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BLUE`); logger.debug(
{ buy, sell, diferencia: diff, porcentaje: pct },
`Cotización BLUE`,
);
const todayStart = startOfToday(); const todayStart = startOfToday();
@@ -76,7 +82,8 @@ export async function processBlueQuote(): Promise<void> {
if (prevDayHistory) { if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell); const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2); prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; prevDayPct =
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
} }
await prisma.quote.update({ await prisma.quote.update({

View File

@@ -10,22 +10,25 @@ 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(
credentials: "omit", "https://api.dolarito.ar/api/frontend/quotations/dolar",
headers: { {
"User-Agent": credentials: "omit",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0", headers: {
Accept: "application/json, text/plain, */*", "User-Agent":
"Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:139.0) Gecko/20100101 Firefox/139.0",
"auth-client": "a4b981d93266b37ea6a646f5b3538f8a", Accept: "application/json, text/plain, */*",
"Sec-Fetch-Dest": "empty", "Accept-Language": "es-ES,es;q=0.8,en-US;q=0.5,en;q=0.3",
"Sec-Fetch-Mode": "cors", "auth-client": "a4b981d93266b37ea6a646f5b3538f8a",
"Sec-Fetch-Site": "same-site", "Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
},
referrer: "https://www.dolarito.ar/",
method: "GET",
mode: "cors",
}, },
referrer: "https://www.dolarito.ar/", );
method: "GET",
mode: "cors",
});
const data: DolaritoApiResponse = await response.json(); const data: DolaritoApiResponse = await response.json();
return { buy: data.oficial.buy, sell: data.oficial.sell }; return { buy: data.oficial.buy, sell: data.oficial.sell };
} }
@@ -59,7 +62,10 @@ export async function processBnaQuote(): Promise<void> {
const diff = roundTo(sell - existingSell, 2); const diff = roundTo(sell - existingSell, 2);
const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0; const pct = existingSell !== 0 ? roundTo((diff / existingSell) * 100, 2) : 0;
logger.debug({ buy, sell, diferencia: diff, porcentaje: pct }, `Cotización BNA`); logger.debug(
{ buy, sell, diferencia: diff, porcentaje: pct },
`Cotización BNA`,
);
const todayStart = startOfToday(); const todayStart = startOfToday();
@@ -76,7 +82,8 @@ export async function processBnaQuote(): Promise<void> {
if (prevDayHistory) { if (prevDayHistory) {
const prevSell = Number(prevDayHistory.sell); const prevSell = Number(prevDayHistory.sell);
prevDayDiff = roundTo(sell - prevSell, 2); prevDayDiff = roundTo(sell - prevSell, 2);
prevDayPct = prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0; prevDayPct =
prevSell !== 0 ? roundTo((prevDayDiff / prevSell) * 100, 2) : 0;
} }
await prisma.quote.update({ await prisma.quote.update({

View File

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

View File

@@ -1,6 +1,6 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache"; import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
export async function getCurrentQuotes(c: Context) { export async function getCurrentQuotes(c: Context) {
const cached = cache.get("quotes:current"); const cached = cache.get("quotes:current");

View File

@@ -1,15 +1,20 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
import type { QuoteType } from "../../../generated/prisma/client"; import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
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");
@@ -23,7 +28,7 @@ export async function getDailyMinMax(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000) ? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now; : 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);
} }
@@ -31,13 +36,15 @@ export async function getDailyMinMax(c: Context) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) return c.json(cached); if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<Array<{ const rows = await prisma.$queryRaw<
date: string; Array<{
minBuy: number; date: string;
maxBuy: number; minBuy: number;
minSell: number; maxBuy: number;
maxSell: number; minSell: 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",

View File

@@ -1,14 +1,19 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache"; import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
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");
@@ -22,7 +27,7 @@ export async function getDailyQuotes(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000) ? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now; : 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);
} }
@@ -30,11 +35,13 @@ export async function getDailyQuotes(c: Context) {
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) return c.json(cached); if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<Array<{ const rows = await prisma.$queryRaw<
date: string; Array<{
buy: number; date: string;
sell: number; buy: 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"

View File

@@ -1,14 +1,19 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache"; import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getHistoricalMinMax(c: Context) { export async function getHistoricalMinMax(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 cacheKey = `quotes:historical:minmax:${rawType}`; const cacheKey = `quotes:historical:minmax:${rawType}`;
@@ -17,12 +22,14 @@ export async function getHistoricalMinMax(c: Context) {
const type = rawType as string; const type = rawType as string;
const rows = await prisma.$queryRaw<Array<{ const rows = await prisma.$queryRaw<
minBuy: number; Array<{
maxBuy: number; minBuy: number;
minBuyDate: string; maxBuy: number;
maxBuyDate: string; minBuyDate: string;
}>>` maxBuyDate: string;
}>
>`
SELECT SELECT
(SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy", (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 MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy",
@@ -30,7 +37,12 @@ export async function getHistoricalMinMax(c: Context) {
(SELECT "timeStamp"::text FROM "quotes_history" WHERE "type" = ${type}::"QuoteType" ORDER BY buy::numeric DESC LIMIT 1) AS "maxBuyDate" (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 }; const result = rows[0] ?? {
minBuy: 0,
maxBuy: 0,
minBuyDate: null,
maxBuyDate: null,
};
cache.set(cacheKey, result); cache.set(cacheKey, result);
return c.json(result); return c.json(result);

View File

@@ -1,15 +1,20 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
import type { QuoteType } from "../../../generated/prisma/client"; import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
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;
@@ -24,7 +29,7 @@ export async function getQuoteHistory(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000) ? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now; : 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);
} }

View File

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

View File

@@ -1,9 +1,9 @@
import cron from "node-cron"; import cron from "node-cron";
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";
async function safeProcess( async function safeProcess(
name: string, name: string,

View File

@@ -1,10 +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 { 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();

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"; import { beforeEach, describe, expect, mock, test } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client"; import { PaymentStatus } from "../src/generated/prisma/client";
const mockPrisma = { const mockPrisma = {
@@ -23,7 +23,9 @@ mock.module("../src/lib/prisma", () => ({
beforeEach(() => { beforeEach(() => {
for (const model of Object.values(mockPrisma)) { for (const model of Object.values(mockPrisma)) {
for (const fn of Object.values(model as Record<string, ReturnType<typeof mock>>)) { for (const fn of Object.values(
model as Record<string, ReturnType<typeof mock>>,
)) {
fn.mockClear(); fn.mockClear();
} }
} }
@@ -32,7 +34,6 @@ beforeEach(() => {
const { const {
createPeriodicExpenseSchema, createPeriodicExpenseSchema,
updatePeriodicExpenseSchema,
createNonPeriodicExpenseSchema, createNonPeriodicExpenseSchema,
payExpenseSchema, payExpenseSchema,
buildDueDate, buildDueDate,
@@ -54,7 +55,7 @@ describe("schemas", () => {
const result = createPeriodicExpenseSchema.parse({ const result = createPeriodicExpenseSchema.parse({
description: "Gimnasio", description: "Gimnasio",
defaultDueDay: 10, defaultDueDay: 10,
defaultAmount: 1500.50, defaultAmount: 1500.5,
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
}); });
expect(result.description).toBe("Gimnasio"); expect(result.description).toBe("Gimnasio");
@@ -67,7 +68,7 @@ describe("schemas", () => {
defaultDueDay: 10, defaultDueDay: 10,
defaultAmount: 100, defaultAmount: 100,
periods: [1], periods: [1],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -78,7 +79,7 @@ describe("schemas", () => {
defaultDueDay: 10, defaultDueDay: 10,
defaultAmount: 100, defaultAmount: 100,
periods: [1], periods: [1],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -89,7 +90,7 @@ describe("schemas", () => {
defaultDueDay: 0, defaultDueDay: 0,
defaultAmount: 100, defaultAmount: 100,
periods: [1], periods: [1],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -100,7 +101,7 @@ describe("schemas", () => {
defaultDueDay: 32, defaultDueDay: 32,
defaultAmount: 100, defaultAmount: 100,
periods: [1], periods: [1],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -111,7 +112,7 @@ describe("schemas", () => {
defaultDueDay: 15, defaultDueDay: 15,
defaultAmount: 0, defaultAmount: 0,
periods: [1], periods: [1],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -122,7 +123,7 @@ describe("schemas", () => {
defaultDueDay: 15, defaultDueDay: 15,
defaultAmount: 100, defaultAmount: 100,
periods: [], periods: [],
}) }),
).toThrow(); ).toThrow();
}); });
@@ -133,7 +134,7 @@ describe("schemas", () => {
defaultDueDay: 15, defaultDueDay: 15,
defaultAmount: 100, defaultAmount: 100,
periods: [0, 13], periods: [0, 13],
}) }),
).toThrow(); ).toThrow();
}); });
}); });
@@ -154,7 +155,7 @@ describe("schemas", () => {
description: "Ropa", description: "Ropa",
amount: -100, amount: -100,
dueDate: "2026-06-15T00:00:00.000Z", dueDate: "2026-06-15T00:00:00.000Z",
}) }),
).toThrow(); ).toThrow();
}); });
}); });
@@ -175,9 +176,7 @@ describe("schemas", () => {
}); });
test("rejects zero amountPayed", () => { test("rejects zero amountPayed", () => {
expect(() => expect(() => payExpenseSchema.parse({ amountPayed: 0 })).toThrow();
payExpenseSchema.parse({ amountPayed: 0 })
).toThrow();
}); });
}); });
}); });
@@ -244,7 +243,12 @@ describe("createPeriodicExpense", () => {
defaultAmount: 1500, defaultAmount: 1500,
periods: [6], periods: [6],
}; };
const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() }; const created = {
id: 1,
...input,
isDeleted: false,
createdAt: new Date(),
};
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created); mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
const result = await createPeriodicExpense(input); const result = await createPeriodicExpense(input);
@@ -339,7 +343,11 @@ describe("listExpenses", () => {
test("combines status, periodicExpenseId, and search filters", async () => { test("combines status, periodicExpenseId, and search filters", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]); mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ status: "PENDING", periodicExpenseId: 1, search: "Agua" }); await listExpenses({
status: "PENDING",
periodicExpenseId: 1,
search: "Agua",
});
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({ expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { where: {
status: "PENDING", status: "PENDING",
@@ -406,13 +414,15 @@ describe("payExpense", () => {
}); });
test("defaults paymentDate to now when not provided", async () => { test("defaults paymentDate to now when not provided", async () => {
mockPrisma.expense.update.mockImplementationOnce(async ({ where, data }) => ({ mockPrisma.expense.update.mockImplementationOnce(
id: where.id, async ({ where, data }) => ({
...data, id: where.id,
status: PaymentStatus.PAYED, ...data,
amountPayed: data.amountPayed, status: PaymentStatus.PAYED,
paymentDate: data.paymentDate, amountPayed: data.amountPayed,
})); paymentDate: data.paymentDate,
}),
);
const result = await payExpense(1, { amountPayed: 1500 }); const result = await payExpense(1, { amountPayed: 1500 });
expect(result.status).toBe(PaymentStatus.PAYED); expect(result.status).toBe(PaymentStatus.PAYED);
@@ -424,7 +434,13 @@ describe("generateExpensesForCurrentMonth", () => {
test("skips existing expenses to avoid duplicates", async () => { test("skips existing expenses to avoid duplicates", async () => {
const { month } = getCurrentYearMonthUTC(); const { month } = getCurrentYearMonthUTC();
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([ mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([
{ id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [month] }, {
id: 1,
description: "Gas",
defaultDueDay: 15,
defaultAmount: 2000,
periods: [month],
},
]); ]);
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 }); mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
@@ -471,11 +487,23 @@ describe("generateExpensesForCurrentMonth", () => {
describe("generateMonthlyExpense", () => { describe("generateMonthlyExpense", () => {
test("creates expense for given periodic expense", async () => { test("creates expense for given periodic expense", async () => {
const periodic = { id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6] }; const periodic = {
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(periodic); id: 1,
description: "Gas",
defaultDueDay: 15,
defaultAmount: 2000,
periods: [6],
};
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce(
periodic,
);
mockPrisma.expense.findFirst.mockResolvedValueOnce(null); mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
mockPrisma.expense.create.mockResolvedValueOnce({ mockPrisma.expense.create.mockResolvedValueOnce({
id: 1, description: "Gas", periodicExpenseId: 1, amount: 2000, status: "PENDING", id: 1,
description: "Gas",
periodicExpenseId: 1,
amount: 2000,
status: "PENDING",
}); });
const result = await generateMonthlyExpense(1); const result = await generateMonthlyExpense(1);
@@ -485,7 +513,11 @@ describe("generateMonthlyExpense", () => {
test("returns existing expense instead of duplicating", async () => { test("returns existing expense instead of duplicating", async () => {
const existing = { id: 99, description: "Gas", periodicExpenseId: 1 }; const existing = { id: 99, description: "Gas", periodicExpenseId: 1 };
mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({ mockPrisma.periodicExpense.findUniqueOrThrow.mockResolvedValueOnce({
id: 1, description: "Gas", defaultDueDay: 15, defaultAmount: 2000, periods: [6], id: 1,
description: "Gas",
defaultDueDay: 15,
defaultAmount: 2000,
periods: [6],
}); });
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing); mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);

View File

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

View File

@@ -6,7 +6,9 @@
"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", "better-auth": "^1.6.11",

View File

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

View File

@@ -1,8 +1,8 @@
import { Menu, Sun, Moon, Monitor, LogOut } from "lucide-react"; import { LogOut, Menu, Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth-context";
import { useSseStatus } from "@/lib/sse-context"; import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { Outlet } from "@tanstack/react-router"; import { Outlet } from "@tanstack/react-router";
import { useState } from "react";
import { LoadingBar } from "@/components/ui/loading-bar";
import { Header } from "./Header"; import { Header } from "./Header";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { LoadingBar } from "@/components/ui/loading-bar";
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,19 @@
import { Link } from "@tanstack/react-router";
import { subDays } from "date-fns";
import { ArrowLeft } 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 { subDays } from "date-fns";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { useHistoricalMinMax, useDailyQuotes } from "@/lib/queries"; import { useDailyQuotes, useHistoricalMinMax } from "@/lib/queries";
import { Link } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -45,8 +45,13 @@ export function BeloAnalysisPage() {
const startDateStr = formatDate(startDate); const startDateStr = formatDate(startDate);
const endDateStr = formatDate(endDate); const endDateStr = formatDate(endDate);
const { data: historical, isLoading: historicalLoading } = useHistoricalMinMax("BELO"); const { data: historical, isLoading: historicalLoading } =
const { data: daily, isLoading: dailyLoading } = useDailyQuotes("BELO", startDateStr, endDateStr); useHistoricalMinMax("BELO");
const { data: daily, isLoading: dailyLoading } = useDailyQuotes(
"BELO",
startDateStr,
endDateStr,
);
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!daily) return []; if (!daily) return [];
@@ -57,7 +62,13 @@ export function BeloAnalysisPage() {
}, [daily]); }, [daily]);
const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => { const { yDomain, minBuyValue, maxBuyValue, avgBuyValue } = useMemo(() => {
if (chartData.length === 0) return { yDomain: [0, 0] as [number, number], minBuyValue: 0, maxBuyValue: 0, avgBuyValue: 0 }; if (chartData.length === 0)
return {
yDomain: [0, 0] as [number, number],
minBuyValue: 0,
maxBuyValue: 0,
avgBuyValue: 0,
};
let min = Infinity; let min = Infinity;
let max = -Infinity; let max = -Infinity;
let sum = 0; let sum = 0;
@@ -92,7 +103,9 @@ export function BeloAnalysisPage() {
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">Mínimo histórico (compra)</p> <p className="text-sm text-muted-foreground mb-2">
Mínimo histórico (compra)
</p>
{historicalLoading ? ( {historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p> <p className="text-sm text-muted-foreground">Cargando...</p>
) : ( ) : (
@@ -109,7 +122,9 @@ export function BeloAnalysisPage() {
)} )}
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-2">Máximo histórico (compra)</p> <p className="text-sm text-muted-foreground mb-2">
Máximo histórico (compra)
</p>
{historicalLoading ? ( {historicalLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p> <p className="text-sm text-muted-foreground">Cargando...</p>
) : ( ) : (
@@ -128,16 +143,26 @@ export function BeloAnalysisPage() {
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">Evolución del precio de compra</h2> <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 items-center gap-4 mb-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Desde</label> <span className="text-xs text-muted-foreground">Desde</span>
<DatePicker value={startDate} onChange={(d) => d && setStartDate(d)} placeholder="Fecha inicio" /> <DatePicker
value={startDate}
onChange={(d) => d && setStartDate(d)}
placeholder="Fecha inicio"
/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Hasta</label> <span className="text-xs text-muted-foreground">Hasta</span>
<DatePicker value={endDate} onChange={(d) => d && setEndDate(d)} placeholder="Fecha fin" /> <DatePicker
value={endDate}
onChange={(d) => d && setEndDate(d)}
placeholder="Fecha fin"
/>
</div> </div>
</div> </div>
@@ -154,7 +179,10 @@ export function BeloAnalysisPage() {
<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="date" dataKey="date"
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
@@ -164,7 +192,9 @@ export function BeloAnalysisPage() {
<YAxis <YAxis
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
className="text-muted-foreground" className="text-muted-foreground"
tickFormatter={(v: number) => `$${priceFormatter.format(v)}`} tickFormatter={(v: number) =>
`$${priceFormatter.format(v)}`
}
width={80} width={80}
domain={yDomain} domain={yDomain}
/> />
@@ -232,19 +262,31 @@ export function BeloAnalysisPage() {
</div> </div>
<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">
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-1)" }} /> <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> <span className="text-xs text-muted-foreground">Máx</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-2)" }} /> <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> <span className="text-xs text-muted-foreground">Mín</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="size-2.5 border-t-2 border-dashed" style={{ borderColor: "var(--chart-3)" }} /> <div
className="size-2.5 border-t-2 border-dashed"
style={{ borderColor: "var(--chart-3)" }}
/>
<span className="text-xs text-muted-foreground">Prom</span> <span className="text-xs text-muted-foreground">Prom</span>
</div> </div>
</div> </div>

View File

@@ -1,26 +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 {
import { Link } from "@tanstack/react-router"; 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 { BarChart3, RefreshCw } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -105,15 +111,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,
@@ -140,7 +144,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) {
@@ -242,28 +247,18 @@ export function BeloPage() {
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}
@@ -282,7 +277,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}%`;
}} }}
> >
@@ -290,7 +286,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>
@@ -336,7 +337,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 }}
@@ -351,11 +355,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}`
} }
@@ -417,11 +421,17 @@ export function BeloPage() {
<div className="flex items-center justify-center gap-4 mt-3"> <div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} /> <span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Compra</span> <span className="text-xs text-muted-foreground">Compra</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} /> <span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">Venta</span> <span className="text-xs text-muted-foreground">Venta</span>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,19 @@
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { RelativeTime } from "@/lib/time";
import type { Quote } from "@/lib/api";
import { useQuotes, useFetchQuotes, useMonthlyPayedTotal, useMonthlyTotals } from "@/lib/queries";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { Quote } from "@/lib/api";
import {
useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals,
useQuotes,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { DashboardExpensesTable } from "./components/DashboardExpensesTable"; import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
@@ -16,7 +21,17 @@ const priceFormatter = new Intl.NumberFormat("es-AR", {
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;
@@ -24,10 +39,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}
> >
@@ -70,7 +88,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>
@@ -81,10 +101,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>
) )
@@ -103,8 +129,14 @@ export function DashboardPage() {
const blue = quotes?.find((q) => q.type === "BLUE"); const blue = quotes?.find((q) => q.type === "BLUE");
const now = new Date(); const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1); const { data: monthlyExpenses } = useMonthlyPayedTotal(
const { data: monthlyTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1); now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(),
now.getMonth() + 1,
);
const isFetching = isLoading || isPending; const isFetching = isLoading || isPending;
@@ -118,20 +150,31 @@ 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">
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Pagos pendientes</p> <p className="text-sm text-muted-foreground">Pagos pendientes</p>
<p className="text-2xl font-bold">${priceFormatter.format(monthlyTotals?.pending ?? 0)}</p> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyTotals?.pending ?? 0)}
</p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p> <p className="text-sm text-muted-foreground">Gastos del mes</p>
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p> <p className="text-2xl font-bold">
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
</p>
</div> </div>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" /> <QuoteCard
quote={belo}
title="BELO"
to="/quotes/belo"
variant="minimal"
/>
<QuoteCard quote={blue} title="BLUE" variant="minimal" /> <QuoteCard quote={blue} title="BLUE" variant="minimal" />
</div> </div>
<DashboardExpensesTable /> <DashboardExpensesTable />

View File

@@ -1,8 +1,8 @@
import { useMemo } from "react";
import { CalendarClock, ExternalLink } from "lucide-react";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { usePendingUpcomingExpenses } from "@/lib/queries"; import { CalendarClock, ExternalLink } from "lucide-react";
import { useMemo } from "react";
import type { PendingUpcomingExpense } from "@/lib/api"; import type { PendingUpcomingExpense } from "@/lib/api";
import { usePendingUpcomingExpenses } from "@/lib/queries";
type GroupedExpense = { type GroupedExpense = {
periodicExpenseId: number; periodicExpenseId: number;
@@ -100,12 +100,15 @@ export function DashboardExpensesTable() {
<CalendarClock className="size-4 text-muted-foreground" /> <CalendarClock className="size-4 text-muted-foreground" />
<h2 className="text-sm font-medium">Gastos por vencer</h2> <h2 className="text-sm font-medium">Gastos por vencer</h2>
{isLoading && ( {isLoading && (
<span className="ml-auto text-xs text-muted-foreground">Cargando...</span> <span className="ml-auto text-xs text-muted-foreground">
Cargando...
</span>
)} )}
{!isLoading && hasData && ( {!isLoading && hasData && (
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
{overdueCount} vencido{overdueCount !== 1 ? "s" : ""} {overdueCount} vencido{overdueCount !== 1 ? "s" : ""}
{upcomingCount > 0 && ` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`} {upcomingCount > 0 &&
` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`}
</span> </span>
)} )}
</div> </div>
@@ -163,16 +166,29 @@ export function DashboardExpensesTable() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/50"> <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">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Monto</th> Descripción
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Vencimiento</th> </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">
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">Estado</th> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{groups.map((group) => ( {groups.map((group) => (
<tr key={group.periodicExpenseId} className="border-b last:border-0 hover:bg-muted/30"> <tr
key={group.periodicExpenseId}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<button <button
type="button" type="button"
@@ -183,8 +199,12 @@ export function DashboardExpensesTable() {
<ExternalLink className="size-3 shrink-0 text-muted-foreground" /> <ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</button> </button>
</td> </td>
<td className="px-3 py-2.5 tabular-nums">{formatAmount(group.totalAmount)}</td> <td className="px-3 py-2.5 tabular-nums">
<td className="px-3 py-2.5 text-muted-foreground">{formatDate(group.earliestDueDate)}</td> {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"> <td className="px-3 py-2.5 text-muted-foreground tabular-nums">
{group.count > 1 ? `${group.count} gastos` : "1 gasto"} {group.count > 1 ? `${group.count} gastos` : "1 gasto"}
</td> </td>
@@ -196,7 +216,9 @@ export function DashboardExpensesTable() {
{hasData && ( {hasData && (
<tr className="border-t bg-muted/50 font-medium"> <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">Total</td>
<td className="px-3 py-2.5 tabular-nums">{formatAmount(grandTotal)}</td> <td className="px-3 py-2.5 tabular-nums">
{formatAmount(grandTotal)}
</td>
<td colSpan={3} /> <td colSpan={3} />
</tr> </tr>
)} )}

View File

@@ -1,8 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { Route } from "@/routes/_authenticated/expenses"; import { Route } from "@/routes/_authenticated/expenses";
import { ExpensesProvider } from "./ExpensesProvider";
import { ExpensesTabContent } from "./components/ExpensesTabContent"; import { ExpensesTabContent } from "./components/ExpensesTabContent";
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent"; import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
import { ExpensesProvider } from "./ExpensesProvider";
const tabs = [ const tabs = [
{ id: "expenses", label: "Gastos" }, { id: "expenses", label: "Gastos" },

View File

@@ -1,30 +1,38 @@
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react";
import { import {
usePeriodicExpenses, createContext,
useCreatePeriodicExpense, type ReactNode,
useUpdatePeriodicExpense, useCallback,
useDeletePeriodicExpense, useContext,
useGenerateMonthlyExpense, useEffect,
useExpenses, useRef,
useCreateNonPeriodicExpense, useState,
usePayExpense, } from "react";
useMonthlyTotals,
useTotalPending,
useImportPeriodicExpenses,
useImportExpenses,
} from "@/lib/queries";
import type { import type {
PeriodicExpense,
Expense,
CreatePeriodicExpenseInput,
CreateNonPeriodicExpenseInput, CreateNonPeriodicExpenseInput,
PayExpenseInput, CreatePeriodicExpenseInput,
PaginatedResponse, Expense,
MonthlyTotals,
MonthlyExpensesTotal,
ImportResult,
ImportExpensesResult, ImportExpensesResult,
ImportResult,
MonthlyExpensesTotal,
MonthlyTotals,
PaginatedResponse,
PayExpenseInput,
PeriodicExpense,
} from "@/lib/api"; } from "@/lib/api";
import {
useCreateNonPeriodicExpense,
useCreatePeriodicExpense,
useDeletePeriodicExpense,
useExpenses,
useGenerateMonthlyExpense,
useImportExpenses,
useImportPeriodicExpenses,
useMonthlyTotals,
usePayExpense,
usePeriodicExpenses,
useTotalPending,
useUpdatePeriodicExpense,
} from "@/lib/queries";
interface ExpensesContextValue { interface ExpensesContextValue {
periodicExpenses: PeriodicExpense[]; periodicExpenses: PeriodicExpense[];
@@ -47,11 +55,18 @@ interface ExpensesContextValue {
totalPending: MonthlyExpensesTotal | undefined; totalPending: MonthlyExpensesTotal | undefined;
isLoadingTotalPending: boolean; isLoadingTotalPending: boolean;
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>; createPeriodicExpense: (
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>; data: CreatePeriodicExpenseInput,
) => Promise<PeriodicExpense>;
updatePeriodicExpense: (
id: number,
data: CreatePeriodicExpenseInput,
) => Promise<void>;
deletePeriodicExpense: (id: number) => Promise<void>; deletePeriodicExpense: (id: number) => Promise<void>;
generateMonthlyExpense: (id: number) => Promise<Expense>; generateMonthlyExpense: (id: number) => Promise<Expense>;
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>; createNonPeriodicExpense: (
data: CreateNonPeriodicExpenseInput,
) => Promise<void>;
payExpense: (id: number, data: PayExpenseInput) => Promise<void>; payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
importPeriodicExpenses: (file: File) => Promise<ImportResult>; importPeriodicExpenses: (file: File) => Promise<ImportResult>;
importExpenses: (file: File) => Promise<ImportExpensesResult>; importExpenses: (file: File) => Promise<ImportExpensesResult>;
@@ -59,11 +74,19 @@ interface ExpensesContextValue {
const ExpensesContext = createContext<ExpensesContextValue | null>(null); const ExpensesContext = createContext<ExpensesContextValue | null>(null);
export function ExpensesProvider({ children, initialPeriodicExpenseId }: { children: ReactNode; initialPeriodicExpenseId?: number }) { export function ExpensesProvider({
children,
initialPeriodicExpenseId,
}: {
children: ReactNode;
initialPeriodicExpenseId?: number;
}) {
const [statusFilter, setStatusFilter] = useState("PENDING"); const [statusFilter, setStatusFilter] = useState("PENDING");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<number | undefined>(initialPeriodicExpenseId); const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState<
number | undefined
>(initialPeriodicExpenseId);
const [searchInput, setSearchInput] = useState(""); const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -81,15 +104,28 @@ export function ExpensesProvider({ children, initialPeriodicExpenseId }: { child
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [periodicExpenseFilter, searchQuery]); }, []);
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses(); const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } =
const { data: expenses = { data: [], total: 0, page: 1, pageSize: 10 }, isLoading: isLoadingExpenses } = usePeriodicExpenses();
useExpenses(statusFilter, page, pageSize, periodicExpenseFilter, searchQuery || undefined); const {
data: expenses = { data: [], total: 0, page: 1, pageSize: 10 },
isLoading: isLoadingExpenses,
} = useExpenses(
statusFilter,
page,
pageSize,
periodicExpenseFilter,
searchQuery || undefined,
);
const now = new Date(); const now = new Date();
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1); const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(
const { data: totalPending, isLoading: isLoadingTotalPending } = useTotalPending(); now.getFullYear(),
now.getMonth() + 1,
);
const { data: totalPending, isLoading: isLoadingTotalPending } =
useTotalPending();
const createPeriodicMutation = useCreatePeriodicExpense(); const createPeriodicMutation = useCreatePeriodicExpense();
const updatePeriodicMutation = useUpdatePeriodicExpense(); const updatePeriodicMutation = useUpdatePeriodicExpense();
@@ -193,6 +229,7 @@ export function ExpensesProvider({ children, initialPeriodicExpenseId }: { child
export function useExpensesContext() { export function useExpensesContext() {
const ctx = useContext(ExpensesContext); const ctx = useContext(ExpensesContext);
if (!ctx) throw new Error("useExpensesContext must be used within ExpensesProvider"); if (!ctx)
throw new Error("useExpensesContext must be used within ExpensesProvider");
return ctx; return ctx;
} }

View File

@@ -1,17 +1,14 @@
import { useState, useRef } from "react"; import { Plus, Search, Upload, X } from "lucide-react";
import { useExpensesContext } from "../ExpensesProvider"; import { useRef, useState } from "react";
import { ExpensesTable } from "./ExpensesTable"; import { Button } from "@/components/ui/button";
import { PayExpenseDialog } from "./PayExpenseDialog"; import { Input } from "@/components/ui/input";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -19,8 +16,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Plus, Upload, Search, X } from "lucide-react";
import type { Expense, ImportExpensesResult } from "@/lib/api"; import type { Expense, ImportExpensesResult } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { ExpensesTable } from "./ExpensesTable";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { PayExpenseDialog } from "./PayExpenseDialog";
const priceFormatter = new Intl.NumberFormat("es-AR", { const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -33,9 +33,7 @@ export function ExpensesTabContent() {
isLoadingExpenses, isLoadingExpenses,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
page,
setPage, setPage,
pageSize,
monthlyTotals, monthlyTotals,
isLoadingTotals, isLoadingTotals,
totalPending, totalPending,
@@ -48,12 +46,16 @@ export function ExpensesTabContent() {
importExpenses, importExpenses,
} = useExpensesContext(); } = useExpensesContext();
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null); const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(
null,
);
const [newExpenseOpen, setNewExpenseOpen] = useState(false); const [newExpenseOpen, setNewExpenseOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false); const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportExpensesResult | null>(null); const [importResult, setImportResult] = useState<ImportExpensesResult | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const filters = [ const filters = [
@@ -72,7 +74,10 @@ export function ExpensesTabContent() {
variant={statusFilter === f.value ? "default" : "outline"} variant={statusFilter === f.value ? "default" : "outline"}
size="xs" size="xs"
className="shrink-0" className="shrink-0"
onClick={() => { setStatusFilter(f.value); setPage(1); }} onClick={() => {
setStatusFilter(f.value);
setPage(1);
}}
> >
{f.label} {f.label}
</Button> </Button>
@@ -98,7 +103,11 @@ export function ExpensesTabContent() {
)} )}
</div> </div>
<Select <Select
value={periodicExpenseFilter !== undefined ? String(periodicExpenseFilter) : "all"} value={
periodicExpenseFilter !== undefined
? String(periodicExpenseFilter)
: "all"
}
onValueChange={(val) => { onValueChange={(val) => {
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val)); setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
setPage(1); setPage(1);
@@ -117,11 +126,20 @@ export function ExpensesTabContent() {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="grid grid-cols-2 gap-2 sm:flex"> <div className="grid grid-cols-2 gap-2 sm:flex">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm" className="min-w-0"> <Button
onClick={() => setImportDialogOpen(true)}
variant="outline"
size="sm"
className="min-w-0"
>
<Upload className="size-4" /> <Upload className="size-4" />
<span className="truncate">Importar</span> <span className="truncate">Importar</span>
</Button> </Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm" className="min-w-0"> <Button
onClick={() => setNewExpenseOpen(true)}
size="sm"
className="min-w-0"
>
<Plus className="size-4" /> <Plus className="size-4" />
<span className="truncate">Nuevo gasto</span> <span className="truncate">Nuevo gasto</span>
</Button> </Button>
@@ -152,7 +170,9 @@ export function ExpensesTabContent() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente del Mes</p> <p className="text-sm text-muted-foreground">
Total Pendiente del Mes
</p>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">
{isLoadingTotals {isLoadingTotals
? "..." ? "..."
@@ -160,7 +180,9 @@ export function ExpensesTabContent() {
</p> </p>
</div> </div>
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente General</p> <p className="text-sm text-muted-foreground">
Total Pendiente General
</p>
<p className="text-2xl font-bold tabular-nums"> <p className="text-2xl font-bold tabular-nums">
{isLoadingTotalPending {isLoadingTotalPending
? "..." ? "..."
@@ -177,19 +199,26 @@ export function ExpensesTabContent() {
}} }}
/> />
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} /> <NewExpenseDialog
open={newExpenseOpen}
onOpenChange={setNewExpenseOpen}
/>
<ResponsiveDialog open={importDialogOpen} onOpenChange={(open) => { <ResponsiveDialog
setImportDialogOpen(open); open={importDialogOpen}
if (!open) { onOpenChange={(open) => {
setImportResult(null); setImportDialogOpen(open);
} if (!open) {
}}> setImportResult(null);
}
}}
>
<ResponsiveDialogContent> <ResponsiveDialogContent>
<ResponsiveDialogHeader> <ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle> <ResponsiveDialogTitle>Importar gastos</ResponsiveDialogTitle>
<ResponsiveDialogDescription> <ResponsiveDialogDescription>
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos. Seleccioná un archivo CSV (delimitado por pipes) para importar
gastos.
</ResponsiveDialogDescription> </ResponsiveDialogDescription>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
@@ -208,15 +237,20 @@ export function ExpensesTabContent() {
</ul> </ul>
{importResult.errors && importResult.errors.length > 0 && ( {importResult.errors && importResult.errors.length > 0 && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3"> <div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="mb-1 text-xs font-medium text-destructive">Errores:</p> <p className="mb-1 text-xs font-medium text-destructive">
Errores:
</p>
<ul className="list-inside list-disc text-xs text-destructive/80"> <ul className="list-inside list-disc text-xs text-destructive/80">
{importResult.errors.map((err, i) => ( {importResult.errors.map((err) => (
<li key={i}>{err}</li> <li key={err}>{err}</li>
))} ))}
</ul> </ul>
</div> </div>
)} )}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}> <Button
className="w-full"
onClick={() => setImportDialogOpen(false)}
>
Cerrar Cerrar
</Button> </Button>
</div> </div>
@@ -239,8 +273,13 @@ export function ExpensesTabContent() {
const result = await importExpenses(file); const result = await importExpenses(file);
setImportResult(result); setImportResult(result);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message =
setImportResult({ imported: 0, skipped: 0, errors: [message] }); err instanceof Error ? err.message : String(err);
setImportResult({
imported: 0,
skipped: 0,
errors: [message],
});
} finally { } finally {
setImporting(false); setImporting(false);
} }

View File

@@ -1,13 +1,18 @@
import { useMemo } from "react";
import { import {
type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
type ColumnDef,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import type { Expense } from "@/lib/api"; import {
ChevronsLeftIcon,
ChevronsRightIcon,
CircleDollarSign,
SkipBackIcon,
SkipForwardIcon,
} from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -16,6 +21,7 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import type { Expense } from "@/lib/api";
interface ExpensesTableProps { interface ExpensesTableProps {
data: Expense[]; data: Expense[];
@@ -37,7 +43,10 @@ function formatExpenseDate(value: string) {
function formatExpenseAmount(expense: Expense) { function formatExpenseAmount(expense: Expense) {
const isPayed = expense.status === "PAYED"; const isPayed = expense.status === "PAYED";
const value = isPayed && expense.amountPayed ? Number(expense.amountPayed) : Number(expense.amount); const value =
isPayed && expense.amountPayed
? Number(expense.amountPayed)
: Number(expense.amount);
return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`; return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`;
} }
@@ -58,7 +67,14 @@ function ExpenseStatusBadge({ status }: { status: Expense["status"] }) {
); );
} }
export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) { export function ExpensesTable({
data,
onPay,
page,
total,
pageSize,
onPageChange,
}: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>( const columns = useMemo<ColumnDef<Expense>[]>(
() => [ () => [
{ {
@@ -74,9 +90,7 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
cell: ({ row }) => { cell: ({ row }) => {
const expense = row.original; const expense = row.original;
return ( return (
<span className="tabular-nums"> <span className="tabular-nums">{formatExpenseAmount(expense)}</span>
{formatExpenseAmount(expense)}
</span>
); );
}, },
}, },
@@ -95,7 +109,11 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
header: "Estado", header: "Estado",
accessorKey: "status", accessorKey: "status",
cell: ({ row }) => { cell: ({ row }) => {
return <ExpenseStatusBadge status={row.getValue("status") as Expense["status"]} />; return (
<ExpenseStatusBadge
status={row.getValue("status") as Expense["status"]}
/>
);
}, },
}, },
{ {
@@ -149,31 +167,32 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages); const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1; const firstItem = total === 0 ? 0 : (page - 1) * pageSize + 1;
const lastItem = Math.min(page * pageSize, total); const lastItem = Math.min(page * pageSize, total);
const mobilePagination = total > 0 ? ( const mobilePagination =
<div className="rounded-lg border bg-card p-2 sm:hidden"> total > 0 ? (
<div className="mb-2 text-center text-xs text-muted-foreground tabular-nums"> <div className="rounded-lg border bg-card p-2 sm:hidden">
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages} <div className="mb-2 text-center text-xs text-muted-foreground tabular-nums">
{firstItem}-{lastItem} de {total} · Página {page} de {totalPages}
</div>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Siguiente
</Button>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> ) : null;
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
Siguiente
</Button>
</div>
</div>
) : null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -181,7 +200,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
<div className="space-y-2 sm:hidden"> <div className="space-y-2 sm:hidden">
{data.map((expense) => ( {data.map((expense) => (
<div key={expense.id} className="rounded-lg border bg-card p-3 text-sm"> <div
key={expense.id}
className="rounded-lg border bg-card p-3 text-sm"
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{expense.description}</p> <p className="truncate font-medium">{expense.description}</p>
@@ -194,9 +216,13 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
<div className="mt-3 flex items-end justify-between gap-3"> <div className="mt-3 flex items-end justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-lg font-semibold tabular-nums">{formatExpenseAmount(expense)}</p> <p className="text-lg font-semibold tabular-nums">
{formatExpenseAmount(expense)}
</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{expense.paymentDate ? `Pagado ${formatExpenseDate(expense.paymentDate)}` : "Sin fecha de pago"} {expense.paymentDate
? `Pagado ${formatExpenseDate(expense.paymentDate)}`
: "Sin fecha de pago"}
</p> </p>
</div> </div>
@@ -231,7 +257,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
key={header.id} key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground" className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
> >
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -239,7 +268,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</thead> </thead>
<tbody> <tbody>
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30"> <tr
key={row.id}
className="border-b last:border-0 hover:bg-muted/30"
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2.5"> <td key={cell.id} className="px-3 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -249,7 +281,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
))} ))}
{table.getRowModel().rows.length === 0 && ( {table.getRowModel().rows.length === 0 && (
<tr> <tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground"> <td
colSpan={columns.length}
className="px-3 py-8 text-center text-sm text-muted-foreground"
>
No hay gastos para mostrar. No hay gastos para mostrar.
</td> </td>
</tr> </tr>
@@ -265,7 +300,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
<PaginationContent> <PaginationContent>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(1); }} onClick={(e) => {
e.preventDefault();
onPageChange(1);
}}
href="#" href="#"
aria-label="Ir a la primera página" aria-label="Ir a la primera página"
className={page <= 1 ? "pointer-events-none opacity-50" : ""} className={page <= 1 ? "pointer-events-none opacity-50" : ""}
@@ -275,7 +313,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
<PaginationPrevious <PaginationPrevious
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }} onClick={(e) => {
e.preventDefault();
if (page > 1) onPageChange(page - 1);
}}
href="#" href="#"
className={page <= 1 ? "pointer-events-none opacity-50" : ""} className={page <= 1 ? "pointer-events-none opacity-50" : ""}
/> />
@@ -283,7 +324,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{currentGroup > 0 && ( {currentGroup > 0 && (
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }} onClick={(e) => {
e.preventDefault();
onPageChange(startPage - 1);
}}
href="#" href="#"
aria-label="Ir al grupo anterior" aria-label="Ir al grupo anterior"
> >
@@ -291,11 +335,17 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
)} )}
{Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => ( {Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i,
).map((p) => (
<PaginationItem key={p}> <PaginationItem key={p}>
<PaginationLink <PaginationLink
isActive={p === page} isActive={p === page}
onClick={(e) => { e.preventDefault(); onPageChange(p); }} onClick={(e) => {
e.preventDefault();
onPageChange(p);
}}
href="#" href="#"
> >
{p} {p}
@@ -305,7 +355,10 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
{(currentGroup + 1) * GROUP_SIZE < totalPages && ( {(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }} onClick={(e) => {
e.preventDefault();
onPageChange(endPage + 1);
}}
href="#" href="#"
aria-label="Ir al siguiente grupo" aria-label="Ir al siguiente grupo"
> >
@@ -315,17 +368,27 @@ export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange
)} )}
<PaginationItem> <PaginationItem>
<PaginationNext <PaginationNext
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }} onClick={(e) => {
e.preventDefault();
if (page < totalPages) onPageChange(page + 1);
}}
href="#" href="#"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""} className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
/> />
</PaginationItem> </PaginationItem>
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }} onClick={(e) => {
e.preventDefault();
onPageChange(totalPages);
}}
href="#" href="#"
aria-label="Ir a la última página" aria-label="Ir a la última página"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""} className={
page >= totalPages ? "pointer-events-none opacity-50" : ""
}
> >
<SkipForwardIcon className="size-4" /> <SkipForwardIcon className="size-4" />
</PaginationLink> </PaginationLink>

View File

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

View File

@@ -1,20 +1,22 @@
import { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useExpensesContext } from "../ExpensesProvider"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button"; import { useExpensesContext } from "../ExpensesProvider";
const newExpenseSchema = z.object({ const newExpenseSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"), description: z
.string()
.min(1, "La descripción es obligatoria")
.max(50, "Máximo 50 caracteres"),
amount: z.number().positive("El monto debe ser mayor a 0"), amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }), dueDate: z.date({ message: "La fecha es obligatoria" }),
}); });
@@ -26,7 +28,10 @@ interface NewExpenseDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) { export function NewExpenseDialog({
open,
onOpenChange,
}: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext(); const { createNonPeriodicExpense } = useExpensesContext();
const { const {
@@ -65,7 +70,11 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle> <ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader> </ResponsiveDialogHeader>
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form
id="new-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium"> <label htmlFor="description" className="text-sm font-medium">
Descripción Descripción
@@ -77,7 +86,9 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/> />
{errors.description && ( {errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span> <span className="text-xs text-destructive">
{errors.description.message}
</span>
)} )}
</div> </div>
@@ -95,14 +106,14 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/> />
{errors.amount && ( {errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span> <span className="text-xs text-destructive">
{errors.amount.message}
</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium"> <span className="text-sm font-medium">Fecha del gasto</span>
Fecha del gasto
</label>
<Controller <Controller
control={control} control={control}
name="dueDate" name="dueDate"
@@ -115,13 +126,19 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
)} )}
/> />
{errors.dueDate && ( {errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span> <span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)} )}
</div> </div>
</form> </form>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" form="new-expense-form" disabled={isSubmitting}> <Button type="submit" form="new-expense-form" disabled={isSubmitting}>

View File

@@ -1,18 +1,18 @@
import { useEffect, useRef } from "react";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import type { Expense } from "@/lib/api"; import { useEffect, useRef } from "react";
import { useExpensesContext } from "../ExpensesProvider"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { import {
ResponsiveDialog, ResponsiveDialog,
ResponsiveDialogContent, ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader, ResponsiveDialogHeader,
ResponsiveDialogTitle, ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"; } from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button"; import type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
const paySchema = z.object({ const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"), amountPayed: z.number().positive("El monto debe ser mayor a 0"),
@@ -27,7 +27,11 @@ interface PayExpenseDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) { export function PayExpenseDialog({
expense,
open,
onOpenChange,
}: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext(); const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null); const amountInputRef = useRef<HTMLInputElement | null>(null);
@@ -94,11 +98,18 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm"> <div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span> <span className="font-medium">{expense.description}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })} Monto original: $
{Number(expense.amount).toLocaleString("es-AR", {
minimumFractionDigits: 2,
})}
</span> </span>
</div> </div>
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> <form
id="pay-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium"> <label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado Monto pagado
@@ -116,14 +127,14 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/> />
{errors.amountPayed && ( {errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span> <span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)} )}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium"> <span className="text-sm font-medium">Fecha de pago</span>
Fecha de pago
</label>
<Controller <Controller
control={control} control={control}
name="paymentDate" name="paymentDate"
@@ -136,13 +147,19 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
)} )}
/> />
{errors.paymentDate && ( {errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span> <span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)} )}
</div> </div>
</form> </form>
<ResponsiveDialogFooter> <ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}> <Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar Cancelar
</Button> </Button>
<Button type="submit" form="pay-form" disabled={isSubmitting}> <Button type="submit" form="pay-form" disabled={isSubmitting}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,11 @@ async function fetcher<T>(url: string): Promise<T> {
return res.json(); return res.json();
} }
async function mutator<T>(url: string, method: string, body?: unknown): Promise<T> { async function mutator<T>(
url: string,
method: string,
body?: unknown,
): Promise<T> {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -154,11 +158,16 @@ export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
return fetcher<PeriodicExpense[]>("/api/periodic-expenses"); return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
} }
export function createPeriodicExpense(data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> { export function createPeriodicExpense(
data: CreatePeriodicExpenseInput,
): Promise<PeriodicExpense> {
return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data); return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data);
} }
export function updatePeriodicExpense(id: number, data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> { export function updatePeriodicExpense(
id: number,
data: CreatePeriodicExpenseInput,
): Promise<PeriodicExpense> {
return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data); return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data);
} }
@@ -167,12 +176,18 @@ export function deletePeriodicExpense(id: number): Promise<void> {
} }
export function generateMonthlyExpense(id: number): Promise<Expense> { export function generateMonthlyExpense(id: number): Promise<Expense> {
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST"); return mutator<Expense>(
`/api/periodic-expenses/${id}/generate-month`,
"POST",
);
} }
export type MonthlyExpensesTotal = { total: number }; export type MonthlyExpensesTotal = { total: number };
export function getMonthlyPayedTotal(year?: number, month?: number): Promise<MonthlyExpensesTotal> { export function getMonthlyPayedTotal(
year?: number,
month?: number,
): Promise<MonthlyExpensesTotal> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (year) params.set("year", String(year)); if (year) params.set("year", String(year));
if (month) params.set("month", String(month)); if (month) params.set("month", String(month));
@@ -202,17 +217,22 @@ export function getExpenses(
if (status) params.set("status", status); if (status) params.set("status", status);
if (page) params.set("page", String(page)); if (page) params.set("page", String(page));
if (pageSize) params.set("pageSize", String(pageSize)); if (pageSize) params.set("pageSize", String(pageSize));
if (periodicExpenseId) params.set("periodicExpenseId", String(periodicExpenseId)); if (periodicExpenseId)
params.set("periodicExpenseId", String(periodicExpenseId));
if (search) params.set("search", search); if (search) params.set("search", search);
const qs = params.toString(); const qs = params.toString();
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`); return fetcher<PaginatedResponse<Expense>>(
`/api/expenses${qs ? `?${qs}` : ""}`,
);
} }
export type PendingUpcomingExpense = Expense & { export type PendingUpcomingExpense = Expense & {
dueType: "overdue" | "upcoming"; dueType: "overdue" | "upcoming";
}; };
export function getPendingUpcomingExpenses(): Promise<PendingUpcomingExpense[]> { export function getPendingUpcomingExpenses(): Promise<
PendingUpcomingExpense[]
> {
return fetcher<PendingUpcomingExpense[]>("/api/expenses/pending-upcoming"); return fetcher<PendingUpcomingExpense[]>("/api/expenses/pending-upcoming");
} }
@@ -220,18 +240,26 @@ export function getTotalPending(): Promise<MonthlyExpensesTotal> {
return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total"); return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total");
} }
export function getMonthlyTotals(year?: number, month?: number): Promise<MonthlyTotals> { export function getMonthlyTotals(
year?: number,
month?: number,
): Promise<MonthlyTotals> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (year) params.set("year", String(year)); if (year) params.set("year", String(year));
if (month) params.set("month", String(month)); if (month) params.set("month", String(month));
return fetcher<MonthlyTotals>(`/api/expenses/totals?${params}`); return fetcher<MonthlyTotals>(`/api/expenses/totals?${params}`);
} }
export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> { export function createNonPeriodicExpense(
data: CreateNonPeriodicExpenseInput,
): Promise<Expense> {
return mutator<Expense>("/api/expenses", "POST", data); return mutator<Expense>("/api/expenses", "POST", data);
} }
export function payExpense(id: number, data: PayExpenseInput): Promise<Expense> { export function payExpense(
id: number,
data: PayExpenseInput,
): Promise<Expense> {
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data); return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
} }

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useCallback, type ReactNode } from "react"; import { createContext, type ReactNode, useCallback, useContext } from "react";
import { authClient, type AuthSession } from "./auth-client"; import { type AuthSession, authClient } from "./auth-client";
interface AuthContextValue { interface AuthContextValue {
session: AuthSession | null; session: AuthSession | null;
@@ -7,7 +7,10 @@ interface AuthContextValue {
isAuthenticated: boolean; isAuthenticated: boolean;
signIn: (password: string) => Promise<void>; signIn: (password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>; changePassword: (
currentPassword: string,
newPassword: string,
) => Promise<void>;
} }
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);

View File

@@ -1,19 +1,33 @@
import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes, getHistoricalMinMax, keepPreviousData,
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi, useMutation,
updatePeriodicExpense as updatePeriodicExpenseApi, useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput,
createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi,
deletePeriodicExpense as deletePeriodicExpenseApi, deletePeriodicExpense as deletePeriodicExpenseApi,
fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi, generateMonthlyExpense as generateMonthlyExpenseApi,
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi, getDailyMinMax,
payExpense as payExpenseApi, getDailyQuotes,
getExpenses,
getHistoricalMinMax,
getMonthlyPayedTotal, getMonthlyPayedTotal,
getMonthlyTotals, getMonthlyTotals,
getTotalPending,
importPeriodicExpenses,
importExpenses,
getPendingUpcomingExpenses, getPendingUpcomingExpenses,
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput, getPeriodicExpenses,
getQuoteHistory,
getQuotes,
getTotalPending,
importExpenses,
importPeriodicExpenses,
type PayExpenseInput,
payExpense as payExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
} from "./api"; } from "./api";
export const quoteKeys = { export const quoteKeys = {
@@ -48,7 +62,11 @@ export function useFetchQuotes() {
}); });
} }
export function useQuoteHistory(type: string, startDate: string, endDate: string) { export function useQuoteHistory(
type: string,
startDate: string,
endDate: string,
) {
return useQuery({ return useQuery({
queryKey: quoteKeys.history(type, startDate, endDate), queryKey: quoteKeys.history(type, startDate, endDate),
queryFn: () => getQuoteHistory(type, startDate, endDate), queryFn: () => getQuoteHistory(type, startDate, endDate),
@@ -57,7 +75,11 @@ export function useQuoteHistory(type: string, startDate: string, endDate: string
}); });
} }
export function useDailyMinMax(type: string, startDate: string, endDate: string) { export function useDailyMinMax(
type: string,
startDate: string,
endDate: string,
) {
return useQuery({ return useQuery({
queryKey: quoteKeys.minMax(type, startDate, endDate), queryKey: quoteKeys.minMax(type, startDate, endDate),
queryFn: () => getDailyMinMax(type, startDate, endDate), queryFn: () => getDailyMinMax(type, startDate, endDate),
@@ -66,7 +88,11 @@ export function useDailyMinMax(type: string, startDate: string, endDate: string)
}); });
} }
export function useDailyQuotes(type: string, startDate: string, endDate: string) { export function useDailyQuotes(
type: string,
startDate: string,
endDate: string,
) {
return useQuery({ return useQuery({
queryKey: quoteKeys.daily(type, startDate, endDate), queryKey: quoteKeys.daily(type, startDate, endDate),
queryFn: () => getDailyQuotes(type, startDate, endDate), queryFn: () => getDailyQuotes(type, startDate, endDate),
@@ -88,10 +114,17 @@ export function useHistoricalMinMax(type: string) {
export const expenseKeys = { export const expenseKeys = {
periodic: ["periodic-expenses"] as const, periodic: ["periodic-expenses"] as const,
all: ["expenses"] as const, all: ["expenses"] as const,
byFilters: (status: string, page: number, pageSize: number, periodicExpenseId?: number, search?: string) => byFilters: (
["expenses", status, page, pageSize, periodicExpenseId, search] as const, status: string,
monthlyTotal: (year: number, month: number) => ["expenses", "monthly-total", year, month] as const, page: number,
monthlyTotals: (year: number, month: number) => ["expenses", "totals", year, month] as const, pageSize: number,
periodicExpenseId?: number,
search?: string,
) => ["expenses", status, page, pageSize, periodicExpenseId, search] as const,
monthlyTotal: (year: number, month: number) =>
["expenses", "monthly-total", year, month] as const,
monthlyTotals: (year: number, month: number) =>
["expenses", "totals", year, month] as const,
totalPending: ["expenses", "pending-total"] as const, totalPending: ["expenses", "pending-total"] as const,
pendingUpcoming: ["expenses", "pending-upcoming"] as const, pendingUpcoming: ["expenses", "pending-upcoming"] as const,
}; };
@@ -106,7 +139,8 @@ export function usePeriodicExpenses() {
export function useCreatePeriodicExpense() { export function useCreatePeriodicExpense() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: CreatePeriodicExpenseInput) => createPeriodicExpenseApi(data), mutationFn: (data: CreatePeriodicExpenseInput) =>
createPeriodicExpenseApi(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic }); queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
}, },
@@ -116,8 +150,13 @@ export function useCreatePeriodicExpense() {
export function useUpdatePeriodicExpense() { export function useUpdatePeriodicExpense() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number; data: CreatePeriodicExpenseInput }) => mutationFn: ({
updatePeriodicExpenseApi(id, data), id,
data,
}: {
id: number;
data: CreatePeriodicExpenseInput;
}) => updatePeriodicExpenseApi(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic }); queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
}, },
@@ -144,17 +183,30 @@ export function useGenerateMonthlyExpense() {
}); });
} }
export function useExpenses(status: string, page: number = 1, pageSize: number = 10, periodicExpenseId?: number, search?: string) { export function useExpenses(
status: string,
page: number = 1,
pageSize: number = 10,
periodicExpenseId?: number,
search?: string,
) {
return useQuery({ return useQuery({
queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search), queryKey: expenseKeys.byFilters(
placeholderData: keepPreviousData, status,
queryFn: () => getExpenses(
status === "all" ? undefined : status,
page, page,
pageSize, pageSize,
periodicExpenseId, periodicExpenseId,
search, search,
), ),
placeholderData: keepPreviousData,
queryFn: () =>
getExpenses(
status === "all" ? undefined : status,
page,
pageSize,
periodicExpenseId,
search,
),
}); });
} }
@@ -189,7 +241,8 @@ export function useMonthlyPayedTotal(year: number, month: number) {
export function useCreateNonPeriodicExpense() { export function useCreateNonPeriodicExpense() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data), mutationFn: (data: CreateNonPeriodicExpenseInput) =>
createNonPeriodicExpenseApi(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all }); queryClient.invalidateQueries({ queryKey: expenseKeys.all });
}, },

View File

@@ -1,5 +1,11 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { quoteKeys } from "./queries"; import { quoteKeys } from "./queries";
type SseStatus = "connecting" | "connected" | "disconnected"; type SseStatus = "connecting" | "connected" | "disconnected";
@@ -17,7 +23,7 @@ export function SseProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const MAX_RETRIES = 10; const MAX_RETRIES = 10;
let retryCount = 0; let retryCount = 0;
let eventSource = new EventSource("/api/quotes/events"); const eventSource = new EventSource("/api/quotes/events");
eventSource.addEventListener("quotes-updated", () => { eventSource.addEventListener("quotes-updated", () => {
queryClient.invalidateQueries({ queryKey: quoteKeys.all }); queryClient.invalidateQueries({ queryKey: quoteKeys.all });
@@ -43,9 +49,5 @@ export function SseProvider({ children }: { children: ReactNode }) {
}; };
}, [queryClient]); }, [queryClient]);
return ( return <SseContext.Provider value={status}>{children}</SseContext.Provider>;
<SseContext.Provider value={status}>
{children}
</SseContext.Provider>
);
} }

View File

@@ -1,4 +1,10 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react"; import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
type Theme = "light" | "dark" | "system"; type Theme = "light" | "dark" | "system";
@@ -17,13 +23,16 @@ const THEME_CYCLE: Theme[] = ["light", "dark", "system"];
function getStoredTheme(): Theme { function getStoredTheme(): Theme {
if (typeof localStorage === "undefined") return "system"; if (typeof localStorage === "undefined") return "system";
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") return stored; if (stored === "light" || stored === "dark" || stored === "system")
return stored;
return "system"; return "system";
} }
function getSystemTheme(): "light" | "dark" { function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light"; if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
function applyThemeClass(resolved: "light" | "dark") { function applyThemeClass(resolved: "light" | "dark") {
@@ -64,7 +73,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "d" && !e.shiftKey && !e.altKey) { if (
(e.metaKey || e.ctrlKey) &&
e.key === "d" &&
!e.shiftKey &&
!e.altKey
) {
e.preventDefault(); e.preventDefault();
cycleTheme(); cycleTheme();
} }

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { quoteKeys } from "./queries"; import { quoteKeys } from "./queries";
export function useQuoteEvents() { export function useQuoteEvents() {

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -1,9 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "@/lib/theme"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "@/lib/auth-context"; import { AuthProvider } from "@/lib/auth-context";
import { ThemeProvider } from "@/lib/theme";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
@@ -17,6 +17,7 @@ const queryClient = new QueryClient({
}, },
}); });
// biome-ignore lint/style/noNonNullAssertion: root element exists in index.html
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>

View File

@@ -8,7 +8,10 @@ export interface RouterContext {
isAuthenticated: boolean; isAuthenticated: boolean;
signIn: (password: string) => Promise<void>; signIn: (password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>; changePassword: (
currentPassword: string,
newPassword: string,
) => Promise<void>;
}; };
} }

View File

@@ -1,4 +1,8 @@
import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router"; import {
createRootRouteWithContext,
Outlet,
redirect,
} from "@tanstack/react-router";
import type { RouterContext } from "@/router"; import type { RouterContext } from "@/router";
export const Route = createRootRouteWithContext<RouterContext>()({ export const Route = createRootRouteWithContext<RouterContext>()({

View File

@@ -1,4 +1,4 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
export const Route = createFileRoute("/_authenticated")({ export const Route = createFileRoute("/_authenticated")({

View File

@@ -1,4 +1,4 @@
import { Outlet, createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated/quotes/belo")({ export const Route = createFileRoute("/_authenticated/quotes/belo")({
component: Outlet, component: Outlet,

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
@@ -51,14 +51,15 @@ function SettingsPage() {
<div className="mx-auto max-w-md space-y-6"> <div className="mx-auto max-w-md space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1> <h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Cambiar contraseña</p>
Cambiar contraseña
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Email</label> <label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input <Input
id="email"
value={auth.session?.user.email ?? ""} value={auth.session?.user.email ?? ""}
readOnly readOnly
className="bg-muted" className="bg-muted"
@@ -66,8 +67,11 @@ function SettingsPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Contraseña actual</label> <label htmlFor="current-password" className="text-sm font-medium">
Contraseña actual
</label>
<Input <Input
id="current-password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={currentPassword} value={currentPassword}
@@ -75,8 +79,11 @@ function SettingsPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Nueva contraseña</label> <label htmlFor="new-password" className="text-sm font-medium">
Nueva contraseña
</label>
<Input <Input
id="new-password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={newPassword} value={newPassword}
@@ -84,21 +91,30 @@ function SettingsPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Confirmar nueva contraseña</label> <label htmlFor="confirm-password" className="text-sm font-medium">
Confirmar nueva contraseña
</label>
<Input <Input
id="confirm-password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
/> />
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
{success && ( {success && (
<p className="text-sm text-emerald-600">Contraseña actualizada correctamente</p> <p className="text-sm text-emerald-600">
Contraseña actualizada correctamente
</p>
)} )}
<Button type="submit" className="w-full" disabled={loading || !currentPassword || !newPassword || !confirmPassword}> <Button
type="submit"
className="w-full"
disabled={
loading || !currentPassword || !newPassword || !confirmPassword
}
>
{loading ? "Guardando..." : "Cambiar contraseña"} {loading ? "Guardando..." : "Cambiar contraseña"}
</Button> </Button>
</form> </form>

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Lock } from "lucide-react"; import { Lock } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
@@ -55,8 +55,11 @@ function LoginPage() {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Email</label> <label htmlFor="login-email" className="text-sm font-medium">
Email
</label>
<Input <Input
id="login-email"
value="jselesan@gmail.com" value="jselesan@gmail.com"
readOnly readOnly
className="bg-muted text-muted-foreground" className="bg-muted text-muted-foreground"
@@ -64,8 +67,11 @@ function LoginPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Contraseña</label> <label htmlFor="login-password" className="text-sm font-medium">
Contraseña
</label>
<Input <Input
id="login-password"
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
@@ -74,9 +80,7 @@ function LoginPage() {
className="text-base" className="text-base"
/> />
</div> </div>
{error && ( {error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-destructive">{error}</p>
)}
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"

View File

@@ -1,8 +1,8 @@
import path from "path"; import path from "node:path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [

40
biome.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

BIN
bun.lockb

Binary file not shown.

View File

@@ -3,13 +3,18 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "conc -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"", "dev": "conc -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"",
"build": "bun run --cwd apps/frontend build" "build": "bun run --cwd apps/frontend build",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format .",
"format:fix": "biome format --write ."
}, },
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
], ],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.16",
"concurrently": "^9.1.0" "concurrently": "^9.1.0"
}, },
"dependencies": { "dependencies": {