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:studio": "prisma studio",
"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": {
"better-auth": "^1.6.11",

View File

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

View File

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

View File

@@ -2,14 +2,14 @@ import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { auth } from "./lib/auth";
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 { eventBus } from "./lib/event-bus";
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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { prisma } from "../../lib/prisma";
import { cache } from "../../lib/cache";
import { PaymentStatus } from "../../generated/prisma/client";
import { cache } from "../../lib/cache";
import { prisma } from "../../lib/prisma";
export const createPeriodicExpenseSchema = z.object({
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();
}
export function buildDueDate(year: number, month: number, dueDay: number): Date {
export function buildDueDate(
year: number,
month: number,
dueDay: number,
): Date {
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
}
@@ -49,15 +53,20 @@ export async function listPeriodicExpenses() {
return data;
}
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
export async function createPeriodicExpense(
data: z.infer<typeof createPeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.create({ data });
const { year, month } = getCurrentYearMonthUTC();
const { month } = getCurrentYearMonthUTC();
const currentMonthApplicable = data.periods.includes(month);
cache.invalidateByPrefix("expenses:");
return { ...result, currentMonthApplicable };
}
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
export async function updatePeriodicExpense(
id: number,
data: z.infer<typeof updatePeriodicExpenseSchema>,
) {
const result = await prisma.periodicExpense.update({ where: { id }, data });
cache.invalidateByPrefix("expenses:");
return result;
@@ -73,7 +82,9 @@ export async function softDeletePeriodicExpense(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 existing = await prisma.expense.findFirst({
@@ -135,7 +146,9 @@ export async function listExpenses(params: {
return result;
}
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
export async function createNonPeriodicExpense(
data: z.infer<typeof createNonPeriodicExpenseSchema>,
) {
const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
@@ -155,8 +168,13 @@ export async function createNonPeriodicExpense(data: z.infer<typeof createNonPer
return result;
}
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
export async function payExpense(
id: number,
data: z.infer<typeof payExpenseSchema>,
) {
const paymentDate = data.paymentDate
? new Date(data.paymentDate)
: new Date();
const result = await prisma.expense.update({
where: { id },
data: {
@@ -189,7 +207,9 @@ export async function getMonthlyTotals(year: number, month: number) {
const cached = cache.get(cacheKey);
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 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
@@ -218,7 +238,17 @@ export async function getTotalPending() {
export async function listPendingUpcomingExpenses() {
const now = new Date();
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
const today = new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
0,
0,
0,
0,
),
);
const fiveDaysLater = new Date(today);
fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5);
@@ -233,7 +263,8 @@ export async function listPendingUpcomingExpenses() {
return data.map((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 { createNonPeriodicExpense, createNonPeriodicExpenseSchema } from "../expenses.service";
import {
createNonPeriodicExpense,
createNonPeriodicExpenseSchema,
} from "../expenses.service";
export async function createNonPeriodicExpenseHandler(c: Context) {
const body = await c.req.json();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,20 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
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;
export async function getDailyMinMax(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const startDateParam = c.req.query("startDate");
@@ -23,7 +28,7 @@ export async function getDailyMinMax(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
@@ -31,13 +36,15 @@ export async function getDailyMinMax(c: Context) {
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<Array<{
date: string;
minBuy: number;
maxBuy: number;
minSell: number;
maxSell: number;
}>>`
const rows = await prisma.$queryRaw<
Array<{
date: string;
minBuy: number;
maxBuy: number;
minSell: number;
maxSell: number;
}>
>`
SELECT
DATE("timeStamp")::text AS date,
MIN(buy::numeric)::float8 AS "minBuy",

View File

@@ -1,14 +1,19 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getDailyQuotes(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const startDateParam = c.req.query("startDate");
@@ -22,7 +27,7 @@ export async function getDailyQuotes(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
@@ -30,11 +35,13 @@ export async function getDailyQuotes(c: Context) {
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<Array<{
date: string;
buy: number;
sell: number;
}>>`
const rows = await prisma.$queryRaw<
Array<{
date: string;
buy: number;
sell: number;
}>
>`
SELECT DATE("timeStamp")::text AS date, buy::numeric::float8 AS buy, sell::numeric::float8 AS sell FROM (
SELECT DISTINCT ON (DATE("timeStamp")) "timeStamp", buy, sell
FROM "quotes_history"

View File

@@ -1,14 +1,19 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getHistoricalMinMax(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const cacheKey = `quotes:historical:minmax:${rawType}`;
@@ -17,12 +22,14 @@ export async function getHistoricalMinMax(c: Context) {
const type = rawType as string;
const rows = await prisma.$queryRaw<Array<{
minBuy: number;
maxBuy: number;
minBuyDate: string;
maxBuyDate: string;
}>>`
const rows = await prisma.$queryRaw<
Array<{
minBuy: number;
maxBuy: number;
minBuyDate: string;
maxBuyDate: string;
}>
>`
SELECT
(SELECT MIN(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "minBuy",
(SELECT MAX(buy::numeric)::float8 FROM "quotes_history" WHERE "type" = ${type}::"QuoteType") AS "maxBuy",
@@ -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"
`;
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);
return c.json(result);

View File

@@ -1,15 +1,20 @@
import type { Context } from "hono";
import { prisma } from "../../../lib/prisma";
import { cache } from "../../../lib/cache";
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;
export async function getQuoteHistory(c: Context) {
const rawType = c.req.param("type")!.toUpperCase();
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as typeof VALID_TYPES[number])) {
return c.json({ error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}` }, 400);
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const type = rawType as QuoteType;
@@ -24,7 +29,7 @@ export async function getQuoteHistory(c: Context) {
? new Date(new Date(endDateParam).getTime() + 86_400_000)
: now;
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}

View File

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

View File

@@ -1,9 +1,9 @@
import cron from "node-cron";
import { eventBus } from "../../lib/event-bus";
import { logger } from "../../lib/logger";
import { processBeloQuote } from "./belo.service";
import { processBlueQuote } from "./blue.service";
import { processBnaQuote } from "./bna.service";
import { logger } from "../../lib/logger";
import { eventBus } from "../../lib/event-bus";
async function safeProcess(
name: string,

View File

@@ -1,10 +1,10 @@
import { Hono } from "hono";
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
import { fetchQuotes } from "./handlers/fetchQuotes";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getCurrentQuotes } from "./handlers/getCurrentQuotes";
import { getDailyMinMax } from "./handlers/getDailyMinMax";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { quoteEvents } from "./handlers/quoteEvents";
const app = new Hono();

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

View File

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

View File

@@ -6,7 +6,9 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"lint": "biome check src/",
"lint:fix": "biome check --write src/"
},
"dependencies": {
"better-auth": "^1.6.11",

View File

@@ -1,7 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
import { SseProvider } from "@/lib/sse-context";
import { useAuth } from "@/lib/auth-context";
import { SseProvider } from "@/lib/sse-context";
import { router } from "@/router";
function App() {
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 { useAuth } from "@/lib/auth-context";
import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
import { cn } from "@/lib/utils";
interface HeaderProps {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,32 @@
import { Link } from "@tanstack/react-router";
import { BarChart3, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import {
LineChart,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
Gauge,
GaugeIndicator,
GaugeTrack,
GaugeRange,
GaugeTrack,
GaugeValueText,
} from "@/components/ui/gauge";
import { useQuotes, useQuoteHistory, useDailyMinMax, useDailyQuotes, useFetchQuotes } from "@/lib/queries";
import { Link } from "@tanstack/react-router";
import {
useDailyMinMax,
useDailyQuotes,
useFetchQuotes,
useQuoteHistory,
useQuotes,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils";
import { BarChart3, RefreshCw } from "lucide-react";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
@@ -105,15 +111,13 @@ export function BeloPage() {
const now = new Date();
const todayStr = formatDate(now);
const firstOfMonth = formatDate(new Date(now.getFullYear(), now.getMonth(), 1));
const firstOfMonth = formatDate(
new Date(now.getFullYear(), now.getMonth(), 1),
);
const { data: quotes, isLoading: quotesLoading } = useQuotes();
const { mutate: doFetchQuotes, isPending: fetchPending } = useFetchQuotes();
const { data: dailyMinMax, isLoading: minMaxLoading } = useDailyMinMax(
"BELO",
todayStr,
todayStr,
);
const { data: dailyMinMax } = useDailyMinMax("BELO", todayStr, todayStr);
const { data: monthlyMinMax } = useDailyMinMax(
"BELO",
firstOfMonth,
@@ -140,7 +144,8 @@ export function BeloPage() {
const minSell = maxData ? Number(maxData.minSell) : 0;
const maxSell = maxData ? Number(maxData.maxSell) : 0;
const gaugeValue = currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
const gaugeValue =
currentBuy > 0 && maxBuy > minBuy ? Math.min(currentBuy, maxBuy) : null;
const gaugePercentage = useMemo(() => {
if (gaugeValue !== null && maxBuy > minBuy) {
@@ -242,28 +247,18 @@ export function BeloPage() {
onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
Actualizar
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-5">
<StatCard
label="Precio actual"
buy={currentBuy}
sell={currentSell}
/>
<StatCard
label="Mínimo del día"
buy={minBuy}
sell={minSell}
/>
<StatCard
label="Máximo del día"
buy={maxBuy}
sell={maxSell}
/>
<StatCard label="Precio actual" buy={currentBuy} sell={currentSell} />
<StatCard label="Mínimo del día" buy={minBuy} sell={minSell} />
<StatCard label="Máximo del día" buy={maxBuy} sell={maxSell} />
<StatCard
label="Máximo del mes"
buy={monthlyMax?.maxBuy ?? 0}
@@ -282,7 +277,8 @@ export function BeloPage() {
startAngle={0}
endAngle={360}
getValueText={(v, m, mx) => {
const pct = mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
const pct =
mx === m ? 100 : Math.round(((v - m) / (mx - m)) * 100);
return `${pct}%`;
}}
>
@@ -290,7 +286,12 @@ export function BeloPage() {
<GaugeTrack />
<GaugeRange className={getGaugeColor(gaugePercentage)} />
</GaugeIndicator>
<GaugeValueText className={cn(getGaugeColor(gaugePercentage), "text-sm font-semibold")} />
<GaugeValueText
className={cn(
getGaugeColor(gaugePercentage),
"text-sm font-semibold",
)}
/>
</Gauge>
</div>
</div>
@@ -336,7 +337,10 @@ export function BeloPage() {
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
/>
<XAxis
dataKey="time"
tick={{ fontSize: 11 }}
@@ -351,11 +355,11 @@ export function BeloPage() {
domain={yDomain}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: recharts types
formatter={(value: any) => [
`$${priceFormatter.format(Number(value))}`,
]}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: recharts types
labelFormatter={(label: any) =>
period === "day" ? `Hora: ${label}` : `Fecha: ${label}`
}
@@ -417,11 +421,17 @@ export function BeloPage() {
<div className="flex items-center justify-center gap-4 mt-3">
<div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-1)" }} />
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Compra</span>
</div>
<div className="flex items-center gap-1.5">
<span className="size-2.5 rounded-full" style={{ backgroundColor: "var(--chart-2)" }} />
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">Venta</span>
</div>
</div>

View File

@@ -1,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 { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { Quote } from "@/lib/api";
import {
useFetchQuotes,
useMonthlyPayedTotal,
useMonthlyTotals,
useQuotes,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { DashboardExpensesTable } from "./components/DashboardExpensesTable";
const priceFormatter = new Intl.NumberFormat("es-AR", {
@@ -16,7 +21,17 @@ const priceFormatter = new Intl.NumberFormat("es-AR", {
maximumFractionDigits: 2,
});
function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; title: string; to?: string; variant?: "default" | "minimal" }) {
function QuoteCard({
quote,
title,
to,
variant,
}: {
quote: Quote | undefined;
title: string;
to?: string;
variant?: "default" | "minimal";
}) {
const navigate = useNavigate();
const diff = quote ? Number(quote.difference) : 0;
const pct = quote ? Number(quote.percentage) : 0;
@@ -24,10 +39,13 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
const isDown = diff < 0;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: conditional button
<div
className="rounded-lg border p-4 cursor-pointer"
onClick={() => to && navigate({ to })}
onKeyDown={(e) => { if (e.key === "Enter" && to) navigate({ to }); }}
onKeyDown={(e) => {
if (e.key === "Enter" && to) navigate({ to });
}}
role={to ? "button" : undefined}
tabIndex={to ? 0 : undefined}
>
@@ -70,7 +88,9 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="cursor-default">
<p className="text-2xl font-bold tabular-nums text-left">${priceFormatter.format(Number(quote.buy))}</p>
<p className="text-2xl font-bold tabular-nums text-left">
${priceFormatter.format(Number(quote.buy))}
</p>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Compra: ${priceFormatter.format(Number(quote.buy))}</p>
@@ -81,10 +101,16 @@ function QuoteCard({ quote, title, to, variant }: { quote: Quote | undefined; ti
) : (
<div className="space-y-1">
<p className="text-sm">
Compra: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.buy))}</span>
Compra:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(quote.buy))}
</span>
</p>
<p className="text-sm">
Venta: <span className="text-lg font-bold">${priceFormatter.format(Number(quote.sell))}</span>
Venta:{" "}
<span className="text-lg font-bold">
${priceFormatter.format(Number(quote.sell))}
</span>
</p>
</div>
)
@@ -103,8 +129,14 @@ export function DashboardPage() {
const blue = quotes?.find((q) => q.type === "BLUE");
const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1);
const { data: monthlyTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
const { data: monthlyExpenses } = useMonthlyPayedTotal(
now.getFullYear(),
now.getMonth() + 1,
);
const { data: monthlyTotals } = useMonthlyTotals(
now.getFullYear(),
now.getMonth() + 1,
);
const isFetching = isLoading || isPending;
@@ -118,20 +150,31 @@ export function DashboardPage() {
onClick={() => doFetchQuotes()}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
Actualizar
</button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-lg border p-4">
<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 className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p>
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p>
<p className="text-2xl font-bold">
${priceFormatter.format(monthlyExpenses?.total ?? 0)}
</p>
</div>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
<QuoteCard
quote={belo}
title="BELO"
to="/quotes/belo"
variant="minimal"
/>
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
</div>
<DashboardExpensesTable />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 { 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 {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { useExpensesContext } from "../ExpensesProvider";
const newExpenseSchema = z.object({
description: z.string().min(1, "La descripción es obligatoria").max(50, "Máximo 50 caracteres"),
description: z
.string()
.min(1, "La descripción es obligatoria")
.max(50, "Máximo 50 caracteres"),
amount: z.number().positive("El monto debe ser mayor a 0"),
dueDate: z.date({ message: "La fecha es obligatoria" }),
});
@@ -26,7 +28,10 @@ interface NewExpenseDialogProps {
onOpenChange: (open: boolean) => void;
}
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
export function NewExpenseDialog({
open,
onOpenChange,
}: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext();
const {
@@ -65,7 +70,11 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<form id="new-expense-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<form
id="new-expense-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="description" className="text-sm font-medium">
Descripción
@@ -77,7 +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"
/>
{errors.description && (
<span className="text-xs text-destructive">{errors.description.message}</span>
<span className="text-xs text-destructive">
{errors.description.message}
</span>
)}
</div>
@@ -95,14 +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"
/>
{errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span>
<span className="text-xs text-destructive">
{errors.amount.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha del gasto
</label>
<span className="text-sm font-medium">Fecha del gasto</span>
<Controller
control={control}
name="dueDate"
@@ -115,13 +126,19 @@ export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps)
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
<span className="text-xs text-destructive">
{errors.dueDate.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>

View File

@@ -1,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 type { Expense } from "@/lib/api";
import { useExpensesContext } from "../ExpensesProvider";
import { useEffect, useRef } from "react";
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 {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} 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({
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
@@ -27,7 +27,11 @@ interface PayExpenseDialogProps {
onOpenChange: (open: boolean) => void;
}
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
export function PayExpenseDialog({
expense,
open,
onOpenChange,
}: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext();
const amountInputRef = useRef<HTMLInputElement | null>(null);
@@ -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">
<span className="font-medium">{expense.description}</span>
<span className="text-muted-foreground">
Monto original: ${Number(expense.amount).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
Monto original: $
{Number(expense.amount).toLocaleString("es-AR", {
minimumFractionDigits: 2,
})}
</span>
</div>
<form id="pay-form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<form
id="pay-form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
@@ -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"
/>
{errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
<span className="text-xs text-destructive">
{errors.amountPayed.message}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha de pago
</label>
<span className="text-sm font-medium">Fecha de pago</span>
<Controller
control={control}
name="paymentDate"
@@ -136,13 +147,19 @@ export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDial
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
<span className="text-xs text-destructive">
{errors.paymentDate.message}
</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
<Button
variant="outline"
onClick={() => handleClose(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button type="submit" form="pay-form" disabled={isSubmitting}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,11 @@ async function fetcher<T>(url: string): Promise<T> {
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, {
method,
headers: { "Content-Type": "application/json" },
@@ -154,11 +158,16 @@ export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
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);
}
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);
}
@@ -167,12 +176,18 @@ export function deletePeriodicExpense(id: number): Promise<void> {
}
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 function getMonthlyPayedTotal(year?: number, month?: number): Promise<MonthlyExpensesTotal> {
export function getMonthlyPayedTotal(
year?: number,
month?: number,
): Promise<MonthlyExpensesTotal> {
const params = new URLSearchParams();
if (year) params.set("year", String(year));
if (month) params.set("month", String(month));
@@ -202,17 +217,22 @@ export function getExpenses(
if (status) params.set("status", status);
if (page) params.set("page", String(page));
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);
const qs = params.toString();
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
return fetcher<PaginatedResponse<Expense>>(
`/api/expenses${qs ? `?${qs}` : ""}`,
);
}
export type PendingUpcomingExpense = Expense & {
dueType: "overdue" | "upcoming";
};
export function getPendingUpcomingExpenses(): Promise<PendingUpcomingExpense[]> {
export function getPendingUpcomingExpenses(): Promise<
PendingUpcomingExpense[]
> {
return fetcher<PendingUpcomingExpense[]>("/api/expenses/pending-upcoming");
}
@@ -220,18 +240,26 @@ export function getTotalPending(): Promise<MonthlyExpensesTotal> {
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();
if (year) params.set("year", String(year));
if (month) params.set("month", String(month));
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);
}
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);
}

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useCallback, type ReactNode } from "react";
import { authClient, type AuthSession } from "./auth-client";
import { createContext, type ReactNode, useCallback, useContext } from "react";
import { type AuthSession, authClient } from "./auth-client";
interface AuthContextValue {
session: AuthSession | null;
@@ -7,7 +7,10 @@ interface AuthContextValue {
isAuthenticated: boolean;
signIn: (password: string) => 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);

View File

@@ -1,19 +1,33 @@
import { keepPreviousData, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes, getHistoricalMinMax,
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
type CreateNonPeriodicExpenseInput,
type CreatePeriodicExpenseInput,
createNonPeriodicExpense as createNonPeriodicExpenseApi,
createPeriodicExpense as createPeriodicExpenseApi,
deletePeriodicExpense as deletePeriodicExpenseApi,
fetchQuotes,
generateMonthlyExpense as generateMonthlyExpenseApi,
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
payExpense as payExpenseApi,
getDailyMinMax,
getDailyQuotes,
getExpenses,
getHistoricalMinMax,
getMonthlyPayedTotal,
getMonthlyTotals,
getTotalPending,
importPeriodicExpenses,
importExpenses,
getPendingUpcomingExpenses,
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
getPeriodicExpenses,
getQuoteHistory,
getQuotes,
getTotalPending,
importExpenses,
importPeriodicExpenses,
type PayExpenseInput,
payExpense as payExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
} from "./api";
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({
queryKey: quoteKeys.history(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({
queryKey: quoteKeys.minMax(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({
queryKey: quoteKeys.daily(type, startDate, endDate),
queryFn: () => getDailyQuotes(type, startDate, endDate),
@@ -88,10 +114,17 @@ export function useHistoricalMinMax(type: string) {
export const expenseKeys = {
periodic: ["periodic-expenses"] as const,
all: ["expenses"] as const,
byFilters: (status: string, page: number, 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,
byFilters: (
status: string,
page: number,
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,
pendingUpcoming: ["expenses", "pending-upcoming"] as const,
};
@@ -106,7 +139,8 @@ export function usePeriodicExpenses() {
export function useCreatePeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePeriodicExpenseInput) => createPeriodicExpenseApi(data),
mutationFn: (data: CreatePeriodicExpenseInput) =>
createPeriodicExpenseApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
},
@@ -116,8 +150,13 @@ export function useCreatePeriodicExpense() {
export function useUpdatePeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: CreatePeriodicExpenseInput }) =>
updatePeriodicExpenseApi(id, data),
mutationFn: ({
id,
data,
}: {
id: number;
data: CreatePeriodicExpenseInput;
}) => updatePeriodicExpenseApi(id, data),
onSuccess: () => {
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({
queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search),
placeholderData: keepPreviousData,
queryFn: () => getExpenses(
status === "all" ? undefined : status,
queryKey: expenseKeys.byFilters(
status,
page,
pageSize,
periodicExpenseId,
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() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data),
mutationFn: (data: CreateNonPeriodicExpenseInput) =>
createNonPeriodicExpenseApi(data),
onSuccess: () => {
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 {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { quoteKeys } from "./queries";
type SseStatus = "connecting" | "connected" | "disconnected";
@@ -17,7 +23,7 @@ export function SseProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const MAX_RETRIES = 10;
let retryCount = 0;
let eventSource = new EventSource("/api/quotes/events");
const eventSource = new EventSource("/api/quotes/events");
eventSource.addEventListener("quotes-updated", () => {
queryClient.invalidateQueries({ queryKey: quoteKeys.all });
@@ -43,9 +49,5 @@ export function SseProvider({ children }: { children: ReactNode }) {
};
}, [queryClient]);
return (
<SseContext.Provider value={status}>
{children}
</SseContext.Provider>
);
return <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";
@@ -17,13 +23,16 @@ const THEME_CYCLE: Theme[] = ["light", "dark", "system"];
function getStoredTheme(): Theme {
if (typeof localStorage === "undefined") return "system";
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";
}
function getSystemTheme(): "light" | "dark" {
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") {
@@ -64,7 +73,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
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();
cycleTheme();
}

View File

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

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
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 { 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 { ThemeProvider } from "@/lib/theme";
import App from "./App";
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(
<StrictMode>
<ThemeProvider>

View File

@@ -8,7 +8,10 @@ export interface RouterContext {
isAuthenticated: boolean;
signIn: (password: string) => 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";
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";
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")({
component: Outlet,

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import path from "path";
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
export default defineConfig({
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,
"scripts": {
"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": [
"packages/*",
"apps/*"
],
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"concurrently": "^9.1.0"
},
"dependencies": {