Compare commits

...

6 Commits

Author SHA1 Message Date
e9910f16c9 Merge pull request 'feat/expenses-management' (#1) from feat/expenses-management into main
Reviewed-on: #1
2026-05-29 17:57:43 +00:00
Jose Selesan
d3cf386be5 feat(expenses): enhance expenses management with filtering and search capabilities 2026-05-29 14:56:35 -03:00
Jose Selesan
e99e97aa14 Added payment date to expenses table 2026-05-29 14:37:16 -03:00
Jose Selesan
b1968cece9 feat(expenses): add import functionality for expenses and total pending calculation 2026-05-29 14:30:10 -03:00
Jose Selesan
4b17940134 feat(expenses): add monthly totals and pagination support for expenses 2026-05-29 10:59:40 -03:00
Jose Selesan
e1786f7384 feat(expenses): implement responsive dialog for expense management and create expense provider
- Added ResponsiveDialog component for handling responsive dialogs in the UI.
- Created ExpensesProvider to manage state and API interactions for expenses.
- Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions.
- Implemented ExpensesTable for rendering expense data in a tabular format with actions.
- Added dialogs for creating new expenses and paying existing ones with form validation.
- Introduced PeriodicExpenseForm for managing periodic expenses with month selection.
- Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality.
- Added GenerateCurrentMonthDialog for confirming monthly expense generation.
- Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
2026-05-29 10:29:20 -03:00
48 changed files with 5657 additions and 6437 deletions

View File

@@ -18,7 +18,8 @@
"node-cron": "^4.2.1",
"pg": "^8.21.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
"pino-pretty": "^13.1.3",
"zod": "^4.4.3"
},
"prisma": {
"seed": "bun run prisma/seed.ts"

View File

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

View File

@@ -41,4 +41,41 @@ model QuoteHistory {
type QuoteType
@@map("quotes_history")
}
// Expenses
enum PaymentStatus {
PENDING
PAYED
}
model PeriodicExpense {
id Int @id @default(autoincrement())
description String @db.VarChar(50)
defaultDueDay Int
defaultAmount Decimal @db.Money
periods Int[]
isDeleted Boolean @default(false)
createdAt DateTime @default(now())
expenses Expense[]
@@map("periodic_expenses")
}
model Expense {
id Int @id @default(autoincrement())
description String @db.VarChar(50)
periodicExpense PeriodicExpense? @relation(fields: [periodicExpenseId], references: [id])
periodicExpenseId Int?
year Int
month Int
amount Decimal @db.Money
amountPayed Decimal? @db.Money
status PaymentStatus @default(PENDING)
dueDate DateTime
paymentDate DateTime?
@@map("expenses")
}

View File

@@ -2,6 +2,8 @@ import { Hono } from "hono";
import { serveStatic } from "hono/bun";
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 { logger } from "./lib/logger";
const app = new Hono();
@@ -11,11 +13,13 @@ app.get("/api/health", (c) => {
});
app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter);
app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" }));
startQuoteJob();
startExpenseJob();
logger.info("Backend started");

View File

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

View File

@@ -0,0 +1,33 @@
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 { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
import { getTotalPendingHandler } from "./handlers/getTotalPending";
import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses";
import { importExpensesHandler } from "./handlers/importExpenses";
const app = new Hono();
app.get("/periodic-expenses", listPeriodicExpensesHandler);
app.post("/periodic-expenses", createPeriodicExpenseHandler);
app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
app.post("/periodic-expenses/import", importPeriodicExpensesHandler);
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
app.get("/expenses/totals", getMonthlyTotalsHandler);
app.get("/expenses/pending-total", getTotalPendingHandler);
app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler);
app.post("/expenses/import", importExpensesHandler);
app.put("/expenses/:id/pay", payExpenseHandler);
export default app;

View File

@@ -0,0 +1,214 @@
import { z } from "zod";
import { prisma } from "../../lib/prisma";
import { PaymentStatus } from "../../generated/prisma/client";
export const createPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50),
defaultDueDay: z.number().int().min(1).max(31),
defaultAmount: z.number().positive(),
periods: z.array(z.number().int().min(1).max(12)).min(1),
});
export const updatePeriodicExpenseSchema = createPeriodicExpenseSchema;
export const createNonPeriodicExpenseSchema = z.object({
description: z.string().min(1).max(50),
amount: z.number().positive(),
dueDate: z.string().datetime(),
});
export const payExpenseSchema = z.object({
amountPayed: z.number().positive(),
paymentDate: z.string().datetime().optional(),
});
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate();
}
export function buildDueDate(year: number, month: number, dueDay: number): Date {
const clampedDay = Math.min(dueDay, lastDayOfMonth(year, month));
return new Date(Date.UTC(year, month - 1, clampedDay, 0, 0, 0, 0));
}
export function getCurrentYearMonthUTC(): { year: number; month: number } {
const now = new Date();
return { year: now.getUTCFullYear(), month: now.getUTCMonth() + 1 };
}
export async function listPeriodicExpenses() {
return prisma.periodicExpense.findMany({
where: { isDeleted: false },
orderBy: { description: "asc" },
});
}
export async function createPeriodicExpense(data: z.infer<typeof createPeriodicExpenseSchema>) {
const result = await prisma.periodicExpense.create({ data });
const { year, month } = getCurrentYearMonthUTC();
const currentMonthApplicable = data.periods.includes(month);
return { ...result, currentMonthApplicable };
}
export async function updatePeriodicExpense(id: number, data: z.infer<typeof updatePeriodicExpenseSchema>) {
return prisma.periodicExpense.update({ where: { id }, data });
}
export async function softDeletePeriodicExpense(id: number) {
return prisma.periodicExpense.update({
where: { id },
data: { isDeleted: true },
});
}
export async function generateMonthlyExpense(id: number) {
const periodic = await prisma.periodicExpense.findUniqueOrThrow({ where: { id } });
const { year, month } = getCurrentYearMonthUTC();
const existing = await prisma.expense.findFirst({
where: { periodicExpenseId: id, year, month },
});
if (existing) return existing;
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
return prisma.expense.create({
data: {
description: periodic.description,
periodicExpenseId: id,
year,
month,
amount: periodic.defaultAmount,
dueDate,
status: PaymentStatus.PENDING,
},
});
}
export async function listExpenses(params: {
status?: string;
page?: number;
pageSize?: number;
periodicExpenseId?: number;
search?: string;
}) {
const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params;
const where: Record<string, unknown> = {};
if (status === "PENDING" || status === "PAYED") {
where.status = status;
}
if (periodicExpenseId !== undefined) {
where.periodicExpenseId = periodicExpenseId;
}
if (search) {
where.description = { contains: search, mode: "insensitive" };
}
const [data, total] = await Promise.all([
prisma.expense.findMany({
where,
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.expense.count({ where }),
]);
return { data, total, page, pageSize };
}
export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
const dueDate = new Date(data.dueDate);
const year = dueDate.getUTCFullYear();
const month = dueDate.getUTCMonth() + 1;
return prisma.expense.create({
data: {
description: data.description,
amount: data.amount,
amountPayed: data.amount,
dueDate,
paymentDate: dueDate,
year,
month,
status: PaymentStatus.PAYED,
},
});
}
export async function payExpense(id: number, data: z.infer<typeof payExpenseSchema>) {
const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date();
return prisma.expense.update({
where: { id },
data: {
status: PaymentStatus.PAYED,
amountPayed: data.amountPayed,
paymentDate,
},
});
}
export async function getMonthlyPayedTotal(year: number, month: number) {
const expenses = await prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PAYED },
select: { amountPayed: true },
});
const total = expenses.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0);
return { total };
}
export async function getMonthlyTotals(year: number, month: number) {
const [payed, pending] = await Promise.all([
prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PAYED },
select: { amountPayed: true },
}),
prisma.expense.findMany({
where: { year, month, status: PaymentStatus.PENDING },
select: { amount: true },
}),
]);
return {
payed: payed.reduce((sum, e) => sum + Number(e.amountPayed ?? 0), 0),
pending: pending.reduce((sum, e) => sum + Number(e.amount), 0),
};
}
export async function getTotalPending() {
const expenses = await prisma.expense.findMany({
where: { status: PaymentStatus.PENDING },
select: { amount: true },
});
const total = expenses.reduce((sum, e) => sum + Number(e.amount), 0);
return { total };
}
export async function generateExpensesForCurrentMonth() {
const { year, month } = getCurrentYearMonthUTC();
const periodics = await prisma.periodicExpense.findMany({
where: { isDeleted: false, periods: { has: month } },
});
const results: Array<{ id: number; status: string }> = [];
for (const periodic of periodics) {
const existing = await prisma.expense.findFirst({
where: { periodicExpenseId: periodic.id, year, month },
});
if (existing) {
results.push({ id: periodic.id, status: "already_exists" });
continue;
}
const dueDate = buildDueDate(year, month, periodic.defaultDueDay);
await prisma.expense.create({
data: {
description: periodic.description,
periodicExpenseId: periodic.id,
year,
month,
amount: periodic.defaultAmount,
dueDate,
status: PaymentStatus.PENDING,
},
});
results.push({ id: periodic.id, status: "created" });
}
return results;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,496 @@
import { describe, test, expect, mock, beforeEach } from "bun:test";
import { PaymentStatus } from "../src/generated/prisma/client";
const mockPrisma = {
periodicExpense: {
findMany: mock(),
findUniqueOrThrow: mock(),
create: mock(),
update: mock(),
},
expense: {
findMany: mock(),
findFirst: mock(),
create: mock(),
update: mock(),
count: mock(),
},
};
mock.module("../src/lib/prisma", () => ({
prisma: mockPrisma,
}));
beforeEach(() => {
for (const model of Object.values(mockPrisma)) {
for (const fn of Object.values(model as Record<string, ReturnType<typeof mock>>)) {
fn.mockClear();
}
}
mockPrisma.expense.count.mockResolvedValue(0);
});
const {
createPeriodicExpenseSchema,
updatePeriodicExpenseSchema,
createNonPeriodicExpenseSchema,
payExpenseSchema,
buildDueDate,
getCurrentYearMonthUTC,
listPeriodicExpenses,
createPeriodicExpense,
updatePeriodicExpense,
softDeletePeriodicExpense,
listExpenses,
createNonPeriodicExpense,
payExpense,
generateExpensesForCurrentMonth,
generateMonthlyExpense,
} = await import("../src/modules/expenses/expenses.service");
describe("schemas", () => {
describe("createPeriodicExpenseSchema", () => {
test("accepts valid input", () => {
const result = createPeriodicExpenseSchema.parse({
description: "Gimnasio",
defaultDueDay: 10,
defaultAmount: 1500.50,
periods: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
});
expect(result.description).toBe("Gimnasio");
});
test("rejects empty description", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "",
defaultDueDay: 10,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects description over 50 chars", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "a".repeat(51),
defaultDueDay: 10,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects due day < 1", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 0,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects due day > 31", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 32,
defaultAmount: 100,
periods: [1],
})
).toThrow();
});
test("rejects non-positive amount", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 0,
periods: [1],
})
).toThrow();
});
test("rejects empty periods", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 100,
periods: [],
})
).toThrow();
});
test("rejects invalid period month", () => {
expect(() =>
createPeriodicExpenseSchema.parse({
description: "Test",
defaultDueDay: 15,
defaultAmount: 100,
periods: [0, 13],
})
).toThrow();
});
});
describe("createNonPeriodicExpenseSchema", () => {
test("accepts valid input", () => {
const result = createNonPeriodicExpenseSchema.parse({
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
});
expect(result.description).toBe("Ropa");
});
test("rejects negative amount", () => {
expect(() =>
createNonPeriodicExpenseSchema.parse({
description: "Ropa",
amount: -100,
dueDate: "2026-06-15T00:00:00.000Z",
})
).toThrow();
});
});
describe("payExpenseSchema", () => {
test("accepts valid payment", () => {
const result = payExpenseSchema.parse({
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
});
expect(result.amountPayed).toBe(1400);
});
test("allows missing paymentDate", () => {
const result = payExpenseSchema.parse({ amountPayed: 1400 });
expect(result.amountPayed).toBe(1400);
expect(result.paymentDate).toBeUndefined();
});
test("rejects zero amountPayed", () => {
expect(() =>
payExpenseSchema.parse({ amountPayed: 0 })
).toThrow();
});
});
});
describe("buildDueDate", () => {
test("creates valid date", () => {
const date = buildDueDate(2026, 6, 15);
expect(date.getUTCFullYear()).toBe(2026);
expect(date.getUTCMonth()).toBe(5); // 0-indexed
expect(date.getUTCDate()).toBe(15);
});
test("clamps day to last day of month when dueDay exceeds month length", () => {
const date = buildDueDate(2026, 2, 31);
expect(date.getUTCMonth()).toBe(1); // February (0-indexed)
expect(date.getUTCDate()).toBe(28);
});
test("handles december correctly", () => {
const date = buildDueDate(2026, 12, 31);
expect(date.getUTCFullYear()).toBe(2026);
expect(date.getUTCMonth()).toBe(11);
expect(date.getUTCDate()).toBe(31);
});
test("handles leap year february", () => {
const date = buildDueDate(2024, 2, 29);
expect(date.getUTCFullYear()).toBe(2024);
expect(date.getUTCMonth()).toBe(1);
expect(date.getUTCDate()).toBe(29);
});
});
describe("getCurrentYearMonthUTC", () => {
test("returns valid year and month", () => {
const { year, month } = getCurrentYearMonthUTC();
expect(year).toBeGreaterThan(2020);
expect(month).toBeGreaterThanOrEqual(1);
expect(month).toBeLessThanOrEqual(12);
});
});
describe("listPeriodicExpenses", () => {
test("returns only non-deleted expenses ordered by createdAt desc", async () => {
const mockItems = [
{ id: 1, description: "Gas", isDeleted: false, createdAt: new Date() },
];
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce(mockItems);
const result = await listPeriodicExpenses();
expect(result).toEqual(mockItems);
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
where: { isDeleted: false },
orderBy: { description: "asc" },
});
});
});
describe("createPeriodicExpense", () => {
test("creates and detects current month applicability", async () => {
const input = {
description: "Gimnasio",
defaultDueDay: 10,
defaultAmount: 1500,
periods: [6],
};
const created = { id: 1, ...input, isDeleted: false, createdAt: new Date() };
mockPrisma.periodicExpense.create.mockResolvedValueOnce(created);
const result = await createPeriodicExpense(input);
expect(result.id).toBe(1);
// currentMonthApplicable depends on actual month; just verify truthy/falsy
expect("currentMonthApplicable" in result).toBe(true);
});
});
describe("updatePeriodicExpense", () => {
test("updates and returns the record", async () => {
const input = {
description: "Gas actualizado",
defaultDueDay: 5,
defaultAmount: 2000,
periods: [1, 7],
};
const updated = { id: 1, ...input, isDeleted: false };
mockPrisma.periodicExpense.update.mockResolvedValueOnce(updated);
const result = await updatePeriodicExpense(1, input);
expect(result.description).toBe("Gas actualizado");
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
where: { id: 1 },
data: input,
});
});
});
describe("softDeletePeriodicExpense", () => {
test("sets isDeleted to true", async () => {
const deleted = { id: 1, isDeleted: true };
mockPrisma.periodicExpense.update.mockResolvedValueOnce(deleted);
const result = await softDeletePeriodicExpense(1);
expect(result.isDeleted).toBe(true);
expect(mockPrisma.periodicExpense.update).toHaveBeenCalledWith({
where: { id: 1 },
data: { isDeleted: true },
});
});
});
describe("listExpenses", () => {
test("returns all expenses when no status filter", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({});
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: {},
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("filters by status when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ status: "PENDING" });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("filters by periodicExpenseId when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ periodicExpenseId: 3 });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { periodicExpenseId: 3 },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("filters by search query with case-insensitive contains", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ search: "Gim" });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { description: { contains: "Gim", mode: "insensitive" } },
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
test("combines status, periodicExpenseId, and search filters", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses({ status: "PENDING", periodicExpenseId: 1, search: "Agua" });
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: {
status: "PENDING",
periodicExpenseId: 1,
description: { contains: "Agua", mode: "insensitive" },
},
include: { periodicExpense: true },
orderBy: { dueDate: "desc" },
skip: 0,
take: 10,
});
});
});
describe("createNonPeriodicExpense", () => {
test("creates expense with PAYED status and sets amountPayed", async () => {
const input = {
description: "Ropa",
amount: 2500,
dueDate: "2026-06-15T00:00:00.000Z",
};
const created = {
id: 1,
description: "Ropa",
amount: 2500,
amountPayed: 2500,
status: PaymentStatus.PAYED,
year: 2026,
month: 6,
};
mockPrisma.expense.create.mockResolvedValueOnce(created);
const result = await createNonPeriodicExpense(input);
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(2500);
expect(mockPrisma.expense.create).toHaveBeenCalledWith({
data: expect.objectContaining({
description: "Ropa",
amount: 2500,
amountPayed: 2500,
status: PaymentStatus.PAYED,
paymentDate: expect.any(Date),
}),
});
});
});
describe("payExpense", () => {
test("updates status to PAYED with amountPayed and paymentDate", async () => {
const paidExpense = {
id: 1,
status: PaymentStatus.PAYED,
amountPayed: 1400,
paymentDate: new Date("2026-06-10T00:00:00.000Z"),
};
mockPrisma.expense.update.mockResolvedValueOnce(paidExpense);
const result = await payExpense(1, {
amountPayed: 1400,
paymentDate: "2026-06-10T00:00:00.000Z",
});
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.amountPayed).toBe(1400);
});
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,
}));
const result = await payExpense(1, { amountPayed: 1500 });
expect(result.status).toBe(PaymentStatus.PAYED);
expect(result.paymentDate).toBeInstanceOf(Date);
});
});
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] },
]);
mockPrisma.expense.findFirst.mockResolvedValueOnce({ id: 99 });
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(1);
expect(results[0].status).toBe("already_exists");
expect(mockPrisma.expense.create).not.toHaveBeenCalled();
});
test("creates expenses for applicable periodic expenses", async () => {
const { month } = getCurrentYearMonthUTC();
const periodic = {
id: 1,
description: "Gas",
defaultDueDay: 15,
defaultAmount: 2000,
periods: [month],
};
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([periodic]);
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
mockPrisma.expense.create.mockResolvedValueOnce({
id: 1,
description: "Gas",
periodicExpenseId: 1,
amount: 2000,
status: "PENDING",
});
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(1);
expect(results[0].status).toBe("created");
});
test("ignores deleted periodic expenses", async () => {
mockPrisma.periodicExpense.findMany.mockResolvedValueOnce([]);
const results = await generateExpensesForCurrentMonth();
expect(results).toHaveLength(0);
expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({
where: { isDeleted: false, periods: { has: expect.any(Number) } },
});
});
});
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);
mockPrisma.expense.findFirst.mockResolvedValueOnce(null);
mockPrisma.expense.create.mockResolvedValueOnce({
id: 1, description: "Gas", periodicExpenseId: 1, amount: 2000, status: "PENDING",
});
const result = await generateMonthlyExpense(1);
expect(result.id).toBe(1);
});
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],
});
mockPrisma.expense.findFirst.mockResolvedValueOnce(existing);
const result = await generateMonthlyExpense(1);
expect(result.id).toBe(99);
expect(result).toEqual(existing);
});
});

View File

@@ -3,12 +3,12 @@ import {
require_with_selector
} from "./chunk-NMJBVCD2.js";
import "./chunk-X37QSMNJ.js";
import {
require_react_dom
} from "./chunk-JCMTJHXH.js";
import {
require_jsx_runtime
} from "./chunk-SOQCU2R6.js";
import {
require_react_dom
} from "./chunk-JCMTJHXH.js";
import {
require_react
} from "./chunk-6VO5C3OI.js";

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -9,21 +9,27 @@
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@personal-admin/common": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-query-devtools": "^5.100.11",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.3.0",
"lucide-react": "^1.16.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.76.1",
"recharts": "^3.8.1",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tanstack/react-router": "^1.170.4",

View File

@@ -0,0 +1,87 @@
"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"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("w-full", className)}
classNames={{
root: "w-full",
months: "flex flex-col",
month: "flex flex-col gap-3",
month_caption: "flex items-center justify-center",
caption_label: "hidden",
nav: "flex items-center gap-1",
button_previous:
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
button_next:
"inline-flex items-center justify-center rounded-md size-7 bg-transparent p-0 text-foreground opacity-50 hover:opacity-100 hover:bg-accent",
dropdowns: "flex items-center gap-2",
dropdown_root: "relative inline-flex items-center",
dropdown:
"h-7 w-full appearance-none rounded-md border border-input bg-background pl-2 pr-6 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer",
months_dropdown: "w-28",
years_dropdown: "w-22",
chevron: "size-3 text-muted-foreground",
month_grid: "w-full border-collapse",
weekdays: "flex",
weekday: "w-8 text-xs font-normal text-muted-foreground pt-2 pb-1 text-center",
week: "flex w-full",
day: "p-0 size-8 text-center text-sm",
day_button:
"inline-flex items-center justify-center rounded-md size-8 text-sm font-normal text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-selected:opacity-100",
today: "bg-accent text-accent-foreground rounded-md",
outside: "text-muted-foreground opacity-50",
disabled: "text-muted-foreground opacity-50",
range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground rounded-none",
range_start:
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-l-md",
range_end:
"aria-selected:bg-primary aria-selected:text-primary-foreground rounded-r-md",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-md",
...classNames,
}}
components={{
Chevron: (props) => {
const { orientation, ...rest } = props
if (orientation === "up" || orientation === "down") {
return <ChevronDownIcon className="size-3" {...rest} />
}
const Icon = orientation === "left" ? ChevronLeftIcon : ChevronRightIcon
return <Icon className="size-4" {...rest} />
},
Select: (props) => {
const { className, children, ...rest } = props
return (
<select
className={cn("h-7 appearance-none rounded-md border border-input bg-background px-2 text-xs font-medium text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 cursor-pointer bg-[right_4px_center] bg-no-repeat", className)}
style={{ backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23666' stroke-width='1.5'%3e%3cpath d='M4 6l4 4 4-4'/%3e%3c/svg%3e")` }}
{...rest}
>
{children}
</select>
)
},
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-popover p-4 text-popover-foreground shadow-md outline-none",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2",
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
"data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,225 @@
"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"
const MEDIA_QUERY = "(min-width: 640px)"
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(() => {
if (typeof window === "undefined") return true
return window.matchMedia(query).matches
})
React.useEffect(() => {
const mql = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [query])
return matches
}
interface ResponsiveDialogContextValue {
open: boolean
onOpenChange: (open: boolean) => void
isDesktop: boolean
}
const ResponsiveDialogContext = React.createContext<ResponsiveDialogContextValue | null>(null)
function useResponsiveDialog() {
const ctx = React.useContext(ResponsiveDialogContext)
if (!ctx) throw new Error("ResponsiveDialog components must be used within ResponsiveDialog")
return ctx
}
function ResponsiveDialog({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) {
const [internalOpen, setInternalOpen] = React.useState(false)
const isDesktop = useMediaQuery(MEDIA_QUERY)
const controlled = open !== undefined
const currentOpen = controlled ? open : internalOpen
const setCurrentOpen = controlled ? onOpenChange! : setInternalOpen
return (
<ResponsiveDialogContext value={{ open: currentOpen, onOpenChange: setCurrentOpen, isDesktop }}>
{isDesktop ? (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children}
</DialogPrimitive.Root>
) : (
<DialogPrimitive.Root open={currentOpen} onOpenChange={setCurrentOpen}>
{children}
</DialogPrimitive.Root>
)}
</ResponsiveDialogContext>
)
}
function ResponsiveDialogTrigger({
children,
asChild,
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return (
<DialogPrimitive.Trigger data-slot="responsive-dialog-trigger" {...props}>
{children}
</DialogPrimitive.Trigger>
)
}
function ResponsiveDialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
const { isDesktop, open, onOpenChange } = useResponsiveDialog()
if (isDesktop) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="responsive-dialog-overlay"
className="fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0"
/>
<DialogPrimitive.Content
data-slot="responsive-dialog-content"
className={cn(
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
"flex flex-col gap-4 rounded-xl border bg-popover p-6 text-popover-foreground shadow-lg",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:slide-out-to-left-1/2 data-closed:slide-out-to-top-[48%]",
"duration-200",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-end sm:hidden" data-slot="responsive-dialog-mobile">
<div
className="fixed inset-0 z-40 bg-black/10 supports-backdrop-filter:backdrop-blur-xs"
onClick={() => onOpenChange(false)}
/>
<div
className={cn(
"relative z-50 flex w-full flex-col gap-4 rounded-t-xl border bg-popover p-6 text-popover-foreground shadow-lg",
"animate-in slide-in-from-bottom-10 fade-in-0 duration-200",
className
)}
>
<div className="mx-auto -mt-2 mb-1 h-1.5 w-10 rounded-full bg-muted" />
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="absolute top-3 right-3"
>
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
{children}
</div>
</div>
)
}
function ResponsiveDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-dialog-header"
className={cn("flex flex-col gap-0.5", className)}
{...props}
/>
)
}
function ResponsiveDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="responsive-dialog-footer"
className={cn("mt-2 flex flex-col-reverse sm:flex-row sm:justify-end gap-2", className)}
{...props}
/>
)
}
function ResponsiveDialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="responsive-dialog-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function ResponsiveDialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="responsive-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function ResponsiveDialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="responsive-dialog-close" {...props} />
}
export {
ResponsiveDialog,
ResponsiveDialogTrigger,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogFooter,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogClose,
}

View File

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

View File

@@ -1,7 +1,7 @@
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
import { RelativeTime } from "@/lib/time";
import type { Quote } from "@/lib/api";
import { useQuotes, useFetchQuotes } from "@/lib/queries";
import { useQuotes, useFetchQuotes, useMonthlyPayedTotal } from "@/lib/queries";
import { useNavigate } from "@tanstack/react-router";
import {
Tooltip,
@@ -101,6 +101,9 @@ export function DashboardPage() {
const belo = quotes?.find((q) => q.type === "BELO");
const blue = quotes?.find((q) => q.type === "BLUE");
const now = new Date();
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1);
const isFetching = isLoading || isPending;
return (
@@ -124,7 +127,7 @@ export function DashboardPage() {
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Gastos del mes</p>
<p className="text-2xl font-bold">$0.00</p>
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p>
</div>
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
<QuoteCard quote={blue} title="BLUE" variant="minimal" />

View File

@@ -1,8 +1,49 @@
export function ExpensesPage() {
import { useState } from "react";
import { ExpensesProvider } from "./ExpensesProvider";
import { ExpensesTabContent } from "./components/ExpensesTabContent";
import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent";
const tabs = [
{ id: "expenses", label: "Gastos" },
{ id: "periodic", label: "Gastos Periódicos" },
] as const;
type TabId = (typeof tabs)[number]["id"];
function ExpensesPageInner() {
const [activeTab, setActiveTab] = useState<TabId>("expenses");
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1>
<p className="text-muted-foreground">Administrá tus gastos acá.</p>
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? "-mb-px border-b-2 border-primary px-3 pb-2 text-sm font-medium text-foreground"
: "px-3 pb-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
}
>
{tab.label}
</button>
))}
</div>
{activeTab === "expenses" && <ExpensesTabContent />}
{activeTab === "periodic" && <PeriodicExpensesTabContent />}
</div>
);
}
export function ExpensesPage() {
return (
<ExpensesProvider>
<ExpensesPageInner />
</ExpensesProvider>
);
}

View File

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

View File

@@ -0,0 +1,254 @@
import { useState, useRef } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { ExpensesTable } from "./ExpensesTable";
import { PayExpenseDialog } from "./PayExpenseDialog";
import { NewExpenseDialog } from "./NewExpenseDialog";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Upload, Search, X } from "lucide-react";
import type { Expense, ImportExpensesResult } from "@/lib/api";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function ExpensesTabContent() {
const {
expenses,
isLoadingExpenses,
statusFilter,
setStatusFilter,
page,
setPage,
pageSize,
monthlyTotals,
isLoadingTotals,
totalPending,
isLoadingTotalPending,
periodicExpenses,
periodicExpenseFilter,
setPeriodicExpenseFilter,
searchInput,
setSearchInput,
importExpenses,
} = useExpensesContext();
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 fileInputRef = useRef<HTMLInputElement>(null);
const filters = [
{ value: "PENDING", label: "Pendientes" },
{ value: "PAYED", label: "Pagados" },
{ value: "all", label: "Todos" },
] as const;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex gap-1">
{filters.map((f) => (
<Button
key={f.value}
variant={statusFilter === f.value ? "default" : "outline"}
size="xs"
onClick={() => { setStatusFilter(f.value); setPage(1); }}
>
{f.label}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<div className="relative w-48">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Buscar por descripción..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex size-5 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
)}
</div>
<Select
value={periodicExpenseFilter !== undefined ? String(periodicExpenseFilter) : "all"}
onValueChange={(val) => {
setPeriodicExpenseFilter(val === "all" ? undefined : Number(val));
setPage(1);
}}
>
<SelectTrigger className="w-52">
<SelectValue placeholder="Todos los gastos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los gastos periódicos</SelectItem>
{periodicExpenses.map((pe) => (
<SelectItem key={pe.id} value={String(pe.id)}>
{pe.description}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
<Upload className="size-4" />
Importar
</Button>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Plus className="size-4" />
Nuevo gasto
</Button>
</div>
</div>
{isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<ExpensesTable
data={expenses.data}
onPay={setPayDialogExpense}
page={expenses.page}
total={expenses.total}
pageSize={expenses.pageSize}
onPageChange={setPage}
/>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pagado</p>
<p className="text-2xl font-bold tabular-nums">
{isLoadingTotals
? "..."
: `$${priceFormatter.format(monthlyTotals?.payed ?? 0)}`}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente del Mes</p>
<p className="text-2xl font-bold tabular-nums">
{isLoadingTotals
? "..."
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground">Total Pendiente General</p>
<p className="text-2xl font-bold tabular-nums">
{isLoadingTotalPending
? "..."
: `$${priceFormatter.format(totalPending?.total ?? 0)}`}
</p>
</div>
</div>
<PayExpenseDialog
expense={payDialogExpense}
open={payDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setPayDialogExpense(null);
}}
/>
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
<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.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
{importResult ? (
<div className="space-y-3 py-4">
<p className="text-sm font-medium">Importación completada</p>
<ul className="space-y-1 text-sm">
<li className="flex items-center gap-2">
<span className="size-2 rounded-full bg-green-500" />
Importados: {importResult.imported}
</li>
<li className="flex items-center gap-2">
<span className="size-2 rounded-full bg-yellow-500" />
Omitidos (duplicados): {importResult.skipped}
</li>
</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>
<ul className="list-inside list-disc text-xs text-destructive/80">
{importResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
Cerrar
</Button>
</div>
) : (
<div className="space-y-4 py-4">
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-xs file:text-primary-foreground hover:file:bg-primary/90"
/>
<Button
className="w-full"
disabled={importing}
onClick={async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
setImporting(true);
try {
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] });
} finally {
setImporting(false);
}
}}
>
{importing ? "Importando..." : "Importar"}
</Button>
</div>
)}
</ResponsiveDialogContent>
</ResponsiveDialog>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useMemo } from "react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import type { Expense } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { CircleDollarSign, ChevronsLeftIcon, ChevronsRightIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface ExpensesTableProps {
data: Expense[];
onPay: (expense: Expense) => void;
page: number;
total: number;
pageSize: number;
onPageChange: (page: number) => void;
}
export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>(
() => [
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("description")}</span>
),
},
{
header: "Monto",
accessorKey: "amount",
cell: ({ row }) => {
const amount = Number(row.getValue("amount"));
return (
<span className="tabular-nums">
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</span>
);
},
},
{
header: "Vencimiento",
accessorKey: "dueDate",
cell: ({ row }) => {
const dueDate = new Date(row.getValue("dueDate"));
return (
<span className="text-muted-foreground">
{dueDate.toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
);
},
},
{
header: "Estado",
accessorKey: "status",
cell: ({ row }) => {
const status = row.getValue("status");
const isPayed = status === "PAYED";
return (
<span
className={
isPayed
? "inline-flex h-5 items-center rounded-md bg-green-100 px-1.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "inline-flex h-5 items-center rounded-md bg-yellow-100 px-1.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
}
>
{isPayed ? "Pagado" : "Pendiente"}
</span>
);
},
},
{
header: "Fecha de pago",
accessorKey: "paymentDate",
cell: ({ row }) => {
const paymentDate = row.getValue("paymentDate");
if (!paymentDate) {
return <span className="text-muted-foreground">--</span>;
}
const date = new Date(paymentDate as string);
return (
<span className="text-muted-foreground">
{date.toLocaleDateString("es-AR", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</span>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const expense = row.original;
if (expense.status === "PAYED") return null;
return (
<div className="flex justify-end">
<Button
variant="outline"
size="xs"
onClick={() => onPay(expense)}
>
<CircleDollarSign className="size-3.5" />
Pagar
</Button>
</div>
);
},
},
],
[onPay],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const GROUP_SIZE = 3;
const currentGroup = Math.floor((page - 1) / GROUP_SIZE);
const startPage = currentGroup * GROUP_SIZE + 1;
const endPage = Math.min(startPage + GROUP_SIZE - 1, totalPages);
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<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())}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-sm text-muted-foreground">
No hay gastos para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(1); }}
href="#"
aria-label="Ir a la primera página"
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
>
<SkipBackIcon className="size-4" />
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationPrevious
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
href="#"
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{currentGroup > 0 && (
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(startPage - 1); }}
href="#"
aria-label="Ir al grupo anterior"
>
<ChevronsLeftIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
{Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPageChange(p); }}
href="#"
>
{p}
</PaginationLink>
</PaginationItem>
))}
{(currentGroup + 1) * GROUP_SIZE < totalPages && (
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(endPage + 1); }}
href="#"
aria-label="Ir al siguiente grupo"
>
<ChevronsRightIcon className="size-4" />
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
href="#"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink
onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }}
href="#"
aria-label="Ir a la última página"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
>
<SkipForwardIcon className="size-4" />
</PaginationLink>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
interface GenerateCurrentMonthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
description: string;
onConfirm: () => void;
onSkip: () => void;
}
export function GenerateCurrentMonthDialog({
open,
onOpenChange,
description,
onConfirm,
onSkip,
}: GenerateCurrentMonthDialogProps) {
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Generar gasto para este mes?</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
<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>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,134 @@
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 { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
const newExpenseSchema = z.object({
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" }),
});
type NewExpenseFormValues = z.infer<typeof newExpenseSchema>;
interface NewExpenseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function NewExpenseDialog({ open, onOpenChange }: NewExpenseDialogProps) {
const { createNonPeriodicExpense } = useExpensesContext();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<NewExpenseFormValues>({
resolver: zodResolver(newExpenseSchema),
defaultValues: {
description: "",
amount: 0,
dueDate: new Date(),
},
});
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: NewExpenseFormValues) {
await createNonPeriodicExpense({
description: data.description,
amount: data.amount,
dueDate: data.dueDate.toISOString(),
});
handleClose(false);
}
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Nuevo gasto</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<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
</label>
<input
id="description"
{...register("description")}
placeholder="Ej: Ropa"
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>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="amount" className="text-sm font-medium">
Monto
</label>
<input
id="amount"
type="number"
step="0.01"
min="0"
{...register("amount", { valueAsNumber: true })}
placeholder="2500"
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.amount && (
<span className="text-xs text-destructive">{errors.amount.message}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha del gasto
</label>
<Controller
control={control}
name="dueDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.dueDate && (
<span className="text-xs text-destructive">{errors.dueDate.message}</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" form="new-expense-form" disabled={isSubmitting}>
Crear gasto
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,136 @@
import { useEffect } 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 { DatePicker } from "@/components/ui/date-picker";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
const paySchema = z.object({
amountPayed: z.number().positive("El monto debe ser mayor a 0"),
paymentDate: z.date({ message: "La fecha es obligatoria" }),
});
type PayFormValues = z.infer<typeof paySchema>;
interface PayExpenseDialogProps {
expense: Expense | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function PayExpenseDialog({ expense, open, onOpenChange }: PayExpenseDialogProps) {
const { payExpense } = useExpensesContext();
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
reset,
} = useForm<PayFormValues>({
resolver: zodResolver(paySchema),
defaultValues: {
amountPayed: 0,
paymentDate: undefined,
},
});
useEffect(() => {
if (expense) {
reset({
amountPayed: Number(expense.amount),
paymentDate: new Date(),
});
}
}, [expense, reset]);
function handleClose(open: boolean) {
onOpenChange(open);
if (!open) reset();
}
async function onSubmit(data: PayFormValues) {
if (!expense) return;
await payExpense(expense.id, {
amountPayed: data.amountPayed,
paymentDate: data.paymentDate.toISOString(),
});
handleClose(false);
}
if (!expense) return null;
return (
<ResponsiveDialog open={open} onOpenChange={handleClose}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Registrar pago</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<div className="flex flex-col gap-1 rounded-lg bg-muted px-3 py-2 text-sm">
<span className="font-medium">{expense.description}</span>
<span className="text-muted-foreground">
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">
<div className="flex flex-col gap-1.5">
<label htmlFor="amountPayed" className="text-sm font-medium">
Monto pagado
</label>
<input
id="amountPayed"
type="number"
step="0.01"
min="0"
{...register("amountPayed", { valueAsNumber: true })}
className="h-8 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{errors.amountPayed && (
<span className="text-xs text-destructive">{errors.amountPayed.message}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
Fecha de pago
</label>
<Controller
control={control}
name="paymentDate"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
placeholder="Seleccionar fecha"
/>
)}
/>
{errors.paymentDate && (
<span className="text-xs text-destructive">{errors.paymentDate.message}</span>
)}
</div>
</form>
<ResponsiveDialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={isSubmitting}>
Cancelar
</Button>
<Button type="submit" form="pay-form" disabled={isSubmitting}>
Confirmar pago
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
}

View File

@@ -0,0 +1,180 @@
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";
const months = [
{ value: 1, label: "Ene" },
{ value: 2, label: "Feb" },
{ value: 3, label: "Mar" },
{ value: 4, label: "Abr" },
{ value: 5, label: "May" },
{ value: 6, label: "Jun" },
{ value: 7, label: "Jul" },
{ value: 8, label: "Ago" },
{ value: 9, label: "Sep" },
{ value: 10, label: "Oct" },
{ value: 11, label: "Nov" },
{ value: 12, label: "Dic" },
];
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"),
defaultAmount: z.number().positive("El monto debe ser mayor a 0"),
periods: z.array(z.number()).min(1, "Seleccioná al menos un mes"),
});
type PeriodicExpenseFormValues = z.infer<typeof periodicExpenseFormSchema>;
interface PeriodicExpenseFormProps {
initialData?: PeriodicExpense;
onSubmit: (data: PeriodicExpenseFormValues) => Promise<void>;
onCancel: () => void;
}
export function PeriodicExpenseForm({ initialData, onSubmit, onCancel }: PeriodicExpenseFormProps) {
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<PeriodicExpenseFormValues>({
resolver: zodResolver(periodicExpenseFormSchema),
defaultValues: initialData
? {
description: initialData.description,
defaultDueDay: initialData.defaultDueDay,
defaultAmount: Number(initialData.defaultAmount),
periods: initialData.periods,
}
: {
description: "",
defaultDueDay: 15,
defaultAmount: 0,
periods: [],
},
});
const selectedPeriods = watch("periods");
function toggleMonth(month: number) {
if (selectedPeriods.includes(month)) {
setValue("periods", selectedPeriods.filter((m) => m !== month), { shouldValidate: true });
} else {
setValue("periods", [...selectedPeriods, month], { shouldValidate: true });
}
}
return (
<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
</label>
<input
id="description"
{...register("description")}
placeholder="Ej: Gimnasio"
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>
)}
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<label htmlFor="defaultAmount" className="text-sm font-medium">
Monto
</label>
<input
id="defaultAmount"
type="number"
step="0.01"
min="0"
{...register("defaultAmount", { valueAsNumber: true })}
placeholder="1500"
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>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5">
<label htmlFor="defaultDueDay" className="text-sm font-medium">
Día de vencimiento
</label>
<input
id="defaultDueDay"
type="number"
min="1"
max="31"
{...register("defaultDueDay", { valueAsNumber: true })}
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>
)}
</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>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const all = months.map((m) => m.value);
setValue("periods", all, { shouldValidate: true });
}}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Seleccionar todos
</button>
<button
type="button"
onClick={() => setValue("periods", [], { shouldValidate: true })}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Deseleccionar todos
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{months.map((m) => (
<button
key={m.value}
type="button"
onClick={() => toggleMonth(m.value)}
className={
selectedPeriods.includes(m.value)
? "h-7 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground transition-colors"
: "h-7 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
}
>
{m.label}
</button>
))}
</div>
{errors.periods && (
<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}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{initialData ? "Guardar cambios" : "Crear gasto periódico"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,223 @@
import { useState, useRef } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import {
ResponsiveDialog,
ResponsiveDialogContent,
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";
export function PeriodicExpensesTabContent() {
const {
periodicExpenses,
isLoadingPeriodic,
createPeriodicExpense,
updatePeriodicExpense,
deletePeriodicExpense,
generateMonthlyExpense,
importPeriodicExpenses,
} = useExpensesContext();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PeriodicExpense | null>(null);
const [generateDialog, setGenerateDialog] = useState<{
open: boolean;
description: string;
periodicExpenseId: number;
}>({ open: false, description: "", periodicExpenseId: 0 });
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function openCreate() {
setEditingItem(null);
setDialogOpen(true);
}
function openEdit(item: PeriodicExpense) {
setEditingItem(item);
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingItem(null);
}
async function handleSubmit(data: { description: string; defaultDueDay: number; defaultAmount: number; periods: number[] }) {
if (editingItem) {
await updatePeriodicExpense(editingItem.id, data);
} else {
const numericData = {
description: data.description,
defaultDueDay: data.defaultDueDay,
defaultAmount: data.defaultAmount,
periods: data.periods,
};
const result = await createPeriodicExpense(numericData);
if (result.currentMonthApplicable) {
setGenerateDialog({
open: true,
description: result.description,
periodicExpenseId: result.id,
});
}
}
closeDialog();
}
async function handleConfirmGenerate() {
await generateMonthlyExpense(generateDialog.periodicExpenseId);
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
}
function handleSkipGenerate() {
setGenerateDialog({ open: false, description: "", periodicExpenseId: 0 });
}
async function handleDelete(id: number) {
if (confirm("¿Eliminar este gasto periódico?")) {
await deletePeriodicExpense(id);
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Administrá tus gastos recurrentes.
</p>
<div className="flex gap-2">
<Button onClick={() => setImportDialogOpen(true)} variant="outline" size="sm">
<Upload className="size-4" />
Importar
</Button>
<Button onClick={openCreate} size="sm">
<Plus className="size-4" />
Nuevo
</Button>
</div>
</div>
{isLoadingPeriodic ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<PeriodicExpensesTable
data={periodicExpenses}
onEdit={openEdit}
onDelete={handleDelete}
/>
)}
<ResponsiveDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
{editingItem ? "Editar gasto periódico" : "Nuevo gasto periódico"}
</ResponsiveDialogTitle>
</ResponsiveDialogHeader>
<PeriodicExpenseForm
initialData={editingItem ?? undefined}
onSubmit={handleSubmit}
onCancel={closeDialog}
/>
</ResponsiveDialogContent>
</ResponsiveDialog>
<GenerateCurrentMonthDialog
open={generateDialog.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);
}
}}>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>Importar gastos periódicos</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Seleccioná un archivo CSV (delimitado por pipes) para importar gastos periódicos.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
{importResult ? (
<div className="space-y-3 py-4">
<p className="text-sm font-medium">Importación completada</p>
<ul className="space-y-1 text-sm">
<li className="flex items-center gap-2">
<span className="size-2 rounded-full bg-green-500" />
Importados: {importResult.imported}
</li>
<li className="flex items-center gap-2">
<span className="size-2 rounded-full bg-yellow-500" />
Omitidos (sin períodos): {importResult.skippedNoPeriods}
</li>
<li className="flex items-center gap-2">
<span className="size-2 rounded-full bg-yellow-500" />
Omitidos (duplicados): {importResult.skippedDuplicate}
</li>
</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>
<ul className="list-inside list-disc text-xs text-destructive/80">
{importResult.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
<Button className="w-full" onClick={() => setImportDialogOpen(false)}>
Cerrar
</Button>
</div>
) : (
<div className="space-y-4 py-4">
<input
ref={fileInputRef}
type="file"
accept=".csv,.txt"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-xs file:text-primary-foreground hover:file:bg-primary/90"
/>
<Button
className="w-full"
disabled={importing}
onClick={async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
setImporting(true);
try {
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] });
} finally {
setImporting(false);
}
}}
>
{importing ? "Importando..." : "Importar"}
</Button>
</div>
)}
</ResponsiveDialogContent>
</ResponsiveDialog>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useMemo } from "react";
import {
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";
const monthLabels = [
"", "Ene", "Feb", "Mar", "Abr", "May", "Jun",
"Jul", "Ago", "Sep", "Oct", "Nov", "Dic",
];
function formatPeriods(periods: number[]): string {
if (periods.length === 0) return "";
if (periods.length === 12) return "Ene-Dic";
const sorted = [...periods].sort((a, b) => a - b);
const ranges: string[] = [];
let start = sorted[0];
let prev = sorted[0];
for (let i = 1; i <= sorted.length; i++) {
const curr = sorted[i];
if (curr === prev + 1) {
prev = curr;
} else {
ranges.push(start === prev ? monthLabels[start] : `${monthLabels[start]}-${monthLabels[prev]}`);
start = curr;
prev = curr;
}
}
return ranges.join(", ");
}
interface PeriodicExpensesTableProps {
data: PeriodicExpense[];
onEdit: (item: PeriodicExpense) => void;
onDelete: (id: number) => void;
}
export function PeriodicExpensesTable({ data, onEdit, onDelete }: PeriodicExpensesTableProps) {
const columns = useMemo<ColumnDef<PeriodicExpense>[]>(
() => [
{
header: "Descripción",
accessorKey: "description",
cell: ({ row }) => (
<span className="font-medium">{row.getValue("description")}</span>
),
},
{
header: "Monto",
accessorKey: "defaultAmount",
cell: ({ row }) => {
const amount = Number(row.getValue("defaultAmount"));
return (
<span className="tabular-nums">
${amount.toLocaleString("es-AR", { minimumFractionDigits: 2 })}
</span>
);
},
},
{
header: "Vence",
accessorKey: "defaultDueDay",
cell: ({ row }) => <span>Día {row.getValue("defaultDueDay")}</span>,
},
{
header: "Meses",
accessorKey: "periods",
cell: ({ row }) => {
const periods: number[] = row.getValue("periods");
return (
<span className="text-xs text-muted-foreground">
{formatPeriods(periods)}
</span>
);
},
},
{
id: "actions",
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => onEdit(row.original)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDelete(row.original.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
),
},
],
[onEdit, onDelete],
);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
>
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<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())}
</td>
))}
</tr>
))}
{table.getRowModel().rows.length === 0 && (
<tr>
<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>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -46,6 +46,18 @@ async function fetcher<T>(url: string): Promise<T> {
return res.json();
}
async function mutator<T>(url: string, method: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function getQuotes(): Promise<Quote[]> {
return fetcher<Quote[]>("/api/quotes");
}
@@ -80,3 +92,163 @@ export function getDailyQuotes(
const params = new URLSearchParams({ startDate, endDate });
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`);
}
// Expenses
export type PeriodicExpense = {
id: number;
description: string;
defaultDueDay: number;
defaultAmount: string;
periods: number[];
isDeleted: boolean;
createdAt: string;
currentMonthApplicable?: boolean;
};
export type Expense = {
id: number;
description: string;
periodicExpenseId: number | null;
year: number;
month: number;
amount: string;
amountPayed: string | null;
status: "PENDING" | "PAYED";
dueDate: string;
paymentDate: string | null;
periodicExpense?: PeriodicExpense | null;
};
export type CreatePeriodicExpenseInput = {
description: string;
defaultDueDay: number;
defaultAmount: number;
periods: number[];
};
export type CreateNonPeriodicExpenseInput = {
description: string;
amount: number;
dueDate: string;
};
export type PayExpenseInput = {
amountPayed: number;
paymentDate?: string;
};
export function getPeriodicExpenses(): Promise<PeriodicExpense[]> {
return fetcher<PeriodicExpense[]>("/api/periodic-expenses");
}
export function createPeriodicExpense(data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
return mutator<PeriodicExpense>("/api/periodic-expenses", "POST", data);
}
export function updatePeriodicExpense(id: number, data: CreatePeriodicExpenseInput): Promise<PeriodicExpense> {
return mutator<PeriodicExpense>(`/api/periodic-expenses/${id}`, "PUT", data);
}
export function deletePeriodicExpense(id: number): Promise<void> {
return mutator<void>(`/api/periodic-expenses/${id}`, "DELETE");
}
export function generateMonthlyExpense(id: number): Promise<Expense> {
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
}
export type MonthlyExpensesTotal = { total: number };
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));
return fetcher<MonthlyExpensesTotal>(`/api/expenses/monthly-total?${params}`);
}
export type PaginatedResponse<T> = {
data: T[];
total: number;
page: number;
pageSize: number;
};
export type MonthlyTotals = {
payed: number;
pending: number;
};
export function getExpenses(
status?: string,
page?: number,
pageSize?: number,
periodicExpenseId?: number,
search?: string,
): Promise<PaginatedResponse<Expense>> {
const params = new URLSearchParams();
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 (search) params.set("search", search);
const qs = params.toString();
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
}
export function getTotalPending(): Promise<MonthlyExpensesTotal> {
return fetcher<MonthlyExpensesTotal>("/api/expenses/pending-total");
}
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> {
return mutator<Expense>("/api/expenses", "POST", data);
}
export function payExpense(id: number, data: PayExpenseInput): Promise<Expense> {
return mutator<Expense>(`/api/expenses/${id}/pay`, "PUT", data);
}
export type ImportResult = {
imported: number;
skipped: number;
skippedNoPeriods: number;
skippedDuplicate: number;
errors?: string[];
};
export type ImportExpensesResult = {
imported: number;
skipped: number;
errors?: string[];
};
export function importExpenses(file: File): Promise<ImportExpensesResult> {
const formData = new FormData();
formData.append("file", file);
return fetch("/api/expenses/import", {
method: "POST",
body: formData,
}).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json();
});
}
export function importPeriodicExpenses(file: File): Promise<ImportResult> {
const formData = new FormData();
formData.append("file", file);
return fetch("/api/periodic-expenses/import", {
method: "POST",
body: formData,
}).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json();
});
}

View File

@@ -1,5 +1,19 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes } from "./api";
import {
getQuotes, fetchQuotes, getQuoteHistory, getDailyMinMax, getDailyQuotes,
getPeriodicExpenses, createPeriodicExpense as createPeriodicExpenseApi,
updatePeriodicExpense as updatePeriodicExpenseApi,
deletePeriodicExpense as deletePeriodicExpenseApi,
generateMonthlyExpense as generateMonthlyExpenseApi,
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
payExpense as payExpenseApi,
getMonthlyPayedTotal,
getMonthlyTotals,
getTotalPending,
importPeriodicExpenses,
importExpenses,
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
} from "./api";
export const quoteKeys = {
all: ["quotes"] as const,
@@ -57,3 +71,138 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
refetchInterval: 60_000,
});
}
// Expenses
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,
totalPending: ["expenses", "pending-total"] as const,
};
export function usePeriodicExpenses() {
return useQuery({
queryKey: expenseKeys.periodic,
queryFn: getPeriodicExpenses,
});
}
export function useCreatePeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePeriodicExpenseInput) => createPeriodicExpenseApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
},
});
}
export function useUpdatePeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: CreatePeriodicExpenseInput }) =>
updatePeriodicExpenseApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
},
});
}
export function useDeletePeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deletePeriodicExpenseApi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
},
});
}
export function useGenerateMonthlyExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => generateMonthlyExpenseApi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}
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),
queryFn: () => getExpenses(
status === "all" ? undefined : status,
page,
pageSize,
periodicExpenseId,
search,
),
});
}
export function useMonthlyTotals(year: number, month: number) {
return useQuery({
queryKey: expenseKeys.monthlyTotals(year, month),
queryFn: () => getMonthlyTotals(year, month),
});
}
export function useTotalPending() {
return useQuery({
queryKey: expenseKeys.totalPending,
queryFn: getTotalPending,
});
}
export function useMonthlyPayedTotal(year: number, month: number) {
return useQuery({
queryKey: expenseKeys.monthlyTotal(year, month),
queryFn: () => getMonthlyPayedTotal(year, month),
});
}
export function useCreateNonPeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}
export function useImportExpenses() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => importExpenses(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}
export function useImportPeriodicExpenses() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => importPeriodicExpenses(file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.periodic });
},
});
}
export function usePayExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: PayExpenseInput }) =>
payExpenseApi(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}

BIN
bun.lockb

Binary file not shown.