feat/expenses-management #1

Merged
jselesan merged 5 commits from feat/expenses-management into main 2026-05-29 17:57:44 +00:00
39 changed files with 4338 additions and 6435 deletions
Showing only changes of commit e1786f7384 - Show all commits

View File

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

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 type QuoteType
@@map("quotes_history") @@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 { serveStatic } from "hono/bun";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import expensesRouter from "./modules/expenses/expenses.routes";
import { startExpenseJob } from "./modules/expenses/expense.job";
import { logger } from "./lib/logger"; import { logger } from "./lib/logger";
const app = new Hono(); const app = new Hono();
@@ -11,11 +13,13 @@ app.get("/api/health", (c) => {
}); });
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter);
app.use("/assets/*", serveStatic({ root: "./web" })); app.use("/assets/*", serveStatic({ root: "./web" }));
app.get("*", serveStatic({ path: "./web/index.html" })); app.get("*", serveStatic({ path: "./web/index.html" }));
startQuoteJob(); startQuoteJob();
startExpenseJob();
logger.info("Backend started"); 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,23 @@
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";
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/:id/generate-month", generateMonthlyExpenseHandler);
app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler);
app.put("/expenses/:id/pay", payExpenseHandler);
export default app;

View File

@@ -0,0 +1,160 @@
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: { createdAt: "desc" },
});
}
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(status?: string) {
const where: Record<string, unknown> = {};
if (status === "PENDING" || status === "PAYED") {
where.status = status;
}
return prisma.expense.findMany({
where,
include: { periodicExpense: true },
orderBy: { dueDate: "asc" },
});
}
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 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,8 @@
import type { Context } from "hono";
import { listExpenses } from "../expenses.service";
export async function listExpensesHandler(c: Context) {
const status = c.req.query("status");
const expenses = await listExpenses(status);
return c.json(expenses);
}

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,450 @@
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(),
},
};
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();
}
}
});
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: { createdAt: "desc" },
});
});
});
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: "asc" },
});
});
test("filters by status when provided", async () => {
mockPrisma.expense.findMany.mockResolvedValueOnce([]);
await listExpenses("PENDING");
expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({
where: { status: "PENDING" },
include: { periodicExpense: true },
orderBy: { dueDate: "asc" },
});
});
});
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 require_with_selector
} from "./chunk-NMJBVCD2.js"; } from "./chunk-NMJBVCD2.js";
import "./chunk-X37QSMNJ.js"; import "./chunk-X37QSMNJ.js";
import {
require_react_dom
} from "./chunk-JCMTJHXH.js";
import { import {
require_jsx_runtime require_jsx_runtime
} from "./chunk-SOQCU2R6.js"; } from "./chunk-SOQCU2R6.js";
import {
require_react_dom
} from "./chunk-JCMTJHXH.js";
import { import {
require_react require_react
} from "./chunk-6VO5C3OI.js"; } from "./chunk-6VO5C3OI.js";

View File

@@ -1,128 +1,206 @@
{ {
"hash": "5f00aae5", "hash": "3d534d7c",
"configHash": "c0c89d92", "configHash": "c0c89d92",
"lockfileHash": "9500aa13", "lockfileHash": "e6e58bf9",
"browserHash": "efa9b066", "browserHash": "5ec6480f",
"optimized": { "optimized": {
"react": { "react": {
"src": "../../../../../node_modules/react/index.js", "src": "../../../../../node_modules/react/index.js",
"file": "react.js", "file": "react.js",
"fileHash": "98e991e0", "fileHash": "fcd31548",
"needsInterop": true "needsInterop": true
}, },
"react-dom": { "react-dom": {
"src": "../../../../../node_modules/react-dom/index.js", "src": "../../../../../node_modules/react-dom/index.js",
"file": "react-dom.js", "file": "react-dom.js",
"fileHash": "3b7ba340", "fileHash": "ab67f690",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-dev-runtime": { "react/jsx-dev-runtime": {
"src": "../../../../../node_modules/react/jsx-dev-runtime.js", "src": "../../../../../node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js", "file": "react_jsx-dev-runtime.js",
"fileHash": "8b662de2", "fileHash": "138cab66",
"needsInterop": true "needsInterop": true
}, },
"react/jsx-runtime": { "react/jsx-runtime": {
"src": "../../../../../node_modules/react/jsx-runtime.js", "src": "../../../../../node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js", "file": "react_jsx-runtime.js",
"fileHash": "edbd65cc", "fileHash": "9805fa83",
"needsInterop": true "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": { "@tanstack/react-query": {
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js", "src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
"file": "@tanstack_react-query.js", "file": "@tanstack_react-query.js",
"fileHash": "705b896d", "fileHash": "ffafe25c",
"needsInterop": false "needsInterop": false
}, },
"@tanstack/react-query-devtools": { "@tanstack/react-query-devtools": {
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js", "src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
"file": "@tanstack_react-query-devtools.js", "file": "@tanstack_react-query-devtools.js",
"fileHash": "70485a02", "fileHash": "f836306e",
"needsInterop": false "needsInterop": false
}, },
"@tanstack/react-router": { "@tanstack/react-router": {
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js", "src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
"file": "@tanstack_react-router.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 "needsInterop": false
}, },
"class-variance-authority": { "class-variance-authority": {
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs", "src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js", "file": "class-variance-authority.js",
"fileHash": "6c7abe67", "fileHash": "79ca80ed",
"needsInterop": false "needsInterop": false
}, },
"clsx": { "clsx": {
"src": "../../../../../node_modules/clsx/dist/clsx.mjs", "src": "../../../../../node_modules/clsx/dist/clsx.mjs",
"file": "clsx.js", "file": "clsx.js",
"fileHash": "97c8d984", "fileHash": "560d7dff",
"needsInterop": false "needsInterop": false
}, },
"lucide-react": { "lucide-react": {
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs", "src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
"file": "lucide-react.js", "file": "lucide-react.js",
"fileHash": "ad567fd3", "fileHash": "27bd6cb0",
"needsInterop": false "needsInterop": false
}, },
"radix-ui": { "radix-ui": {
"src": "../../../../../node_modules/radix-ui/dist/index.mjs", "src": "../../../../../node_modules/radix-ui/dist/index.mjs",
"file": "radix-ui.js", "file": "radix-ui.js",
"fileHash": "10864bf0", "fileHash": "f216ffc2",
"needsInterop": false "needsInterop": false
}, },
"react-dom/client": { "react-dom/client": {
"src": "../../../../../node_modules/react-dom/client.js", "src": "../../../../../node_modules/react-dom/client.js",
"file": "react-dom_client.js", "file": "react-dom_client.js",
"fileHash": "ab77e82f", "fileHash": "a87aab08",
"needsInterop": true "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": { "recharts": {
"src": "../../../../../node_modules/recharts/es6/index.js", "src": "../../../../../node_modules/recharts/es6/index.js",
"file": "recharts.js", "file": "recharts.js",
"fileHash": "d0cc5258", "fileHash": "de40db9e",
"needsInterop": false "needsInterop": false
}, },
"tailwind-merge": { "tailwind-merge": {
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs", "src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js", "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 "needsInterop": false
} }
}, },
"chunks": { "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": { "chunk-NMJBVCD2": {
"file": "chunk-NMJBVCD2.js" "file": "chunk-NMJBVCD2.js"
}, },
"chunk-X37QSMNJ": { "chunk-X37QSMNJ": {
"file": "chunk-X37QSMNJ.js" "file": "chunk-X37QSMNJ.js"
}, },
"VKXKX7EQ-FAVAKZBN": { "chunk-WFNHCR67": {
"file": "VKXKX7EQ-FAVAKZBN.js" "file": "chunk-WFNHCR67.js"
}, },
"ZUJJ2RGI-GFZTQM7R": { "chunk-R6CVIFUM": {
"file": "ZUJJ2RGI-GFZTQM7R.js" "file": "chunk-R6CVIFUM.js"
}, },
"chunk-LSVH5W4R": { "chunk-2EY3U74L": {
"file": "chunk-LSVH5W4R.js" "file": "chunk-2EY3U74L.js"
}, },
"chunk-JCMTJHXH": { "chunk-TYPHK3K7": {
"file": "chunk-JCMTJHXH.js" "file": "chunk-TYPHK3K7.js"
}, },
"chunk-TLZOGT6B": { "chunk-ITOVD2G7": {
"file": "chunk-TLZOGT6B.js" "file": "chunk-ITOVD2G7.js"
}, },
"chunk-A2TD7EYA": { "chunk-OAGFOHTQ": {
"file": "chunk-A2TD7EYA.js" "file": "chunk-OAGFOHTQ.js"
}, },
"chunk-SOQCU2R6": { "chunk-SOQCU2R6": {
"file": "chunk-SOQCU2R6.js" "file": "chunk-SOQCU2R6.js"
}, },
"chunk-JCMTJHXH": {
"file": "chunk-JCMTJHXH.js"
},
"chunk-6VO5C3OI": { "chunk-6VO5C3OI": {
"file": "chunk-6VO5C3OI.js" "file": "chunk-6VO5C3OI.js"
}, },
"chunk-WFNHCR67": {
"file": "chunk-WFNHCR67.js"
},
"chunk-WOOG5QLI": { "chunk-WOOG5QLI": {
"file": "chunk-WOOG5QLI.js" "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 require_with_selector
} from "./chunk-NMJBVCD2.js"; } from "./chunk-NMJBVCD2.js";
import "./chunk-X37QSMNJ.js"; import "./chunk-X37QSMNJ.js";
import {
clsx
} from "./chunk-WFNHCR67.js";
import { import {
require_react_dom require_react_dom
} from "./chunk-JCMTJHXH.js"; } from "./chunk-JCMTJHXH.js";
import { import {
require_react require_react
} from "./chunk-6VO5C3OI.js"; } from "./chunk-6VO5C3OI.js";
import {
clsx
} from "./chunk-WFNHCR67.js";
import { import {
__commonJS, __commonJS,
__export, __export,

View File

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

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,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

@@ -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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-2xl font-semibold">Gastos</h1> <h1 className="text-2xl font-semibold">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> </div>
); );
} }
export function ExpensesPage() {
return (
<ExpensesProvider>
<ExpensesPageInner />
</ExpensesProvider>
);
}

View File

@@ -0,0 +1,120 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import {
usePeriodicExpenses,
useCreatePeriodicExpense,
useUpdatePeriodicExpense,
useDeletePeriodicExpense,
useGenerateMonthlyExpense,
useExpenses,
useCreateNonPeriodicExpense,
usePayExpense,
} from "@/lib/queries";
import type {
PeriodicExpense,
Expense,
CreatePeriodicExpenseInput,
CreateNonPeriodicExpenseInput,
PayExpenseInput,
} from "@/lib/api";
interface ExpensesContextValue {
periodicExpenses: PeriodicExpense[];
isLoadingPeriodic: boolean;
expenses: Expense[];
isLoadingExpenses: boolean;
statusFilter: string;
setStatusFilter: (status: string) => void;
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
deletePeriodicExpense: (id: number) => Promise<void>;
generateMonthlyExpense: (id: number) => Promise<Expense>;
createNonPeriodicExpense: (data: CreateNonPeriodicExpenseInput) => Promise<void>;
payExpense: (id: number, data: PayExpenseInput) => Promise<void>;
}
const ExpensesContext = createContext<ExpensesContextValue | null>(null);
export function ExpensesProvider({ children }: { children: ReactNode }) {
const [statusFilter, setStatusFilter] = useState("PENDING");
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
const { data: expenses = [], isLoading: isLoadingExpenses } = useExpenses(statusFilter);
const createPeriodicMutation = useCreatePeriodicExpense();
const updatePeriodicMutation = useUpdatePeriodicExpense();
const deletePeriodicMutation = useDeletePeriodicExpense();
const generateMonthlyMutation = useGenerateMonthlyExpense();
const createExpenseMutation = useCreateNonPeriodicExpense();
const payExpenseMutation = usePayExpense();
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,
createPeriodicExpense,
updatePeriodicExpense: updatePeriodicExpenseFn,
deletePeriodicExpense: deletePeriodicExpenseFn,
generateMonthlyExpense: generateMonthlyExpenseFn,
createNonPeriodicExpense: createNonPeriodicExpenseFn,
payExpense: payExpenseFn,
}}
>
{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,60 @@
import { useState } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { ExpensesTable } from "./ExpensesTable";
import { PayExpenseDialog } from "./PayExpenseDialog";
import { NewExpenseDialog } from "./NewExpenseDialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import type { Expense } from "@/lib/api";
export function ExpensesTabContent() {
const { expenses, isLoadingExpenses, statusFilter, setStatusFilter } = useExpensesContext();
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
const [newExpenseOpen, setNewExpenseOpen] = useState(false);
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)}
>
{f.label}
</Button>
))}
</div>
<Button onClick={() => setNewExpenseOpen(true)} size="sm">
<Plus className="size-4" />
Nuevo gasto
</Button>
</div>
{isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : (
<ExpensesTable data={expenses} onPay={setPayDialogExpense} />
)}
<PayExpenseDialog
expense={payDialogExpense}
open={payDialogExpense !== null}
onOpenChange={(open) => {
if (!open) setPayDialogExpense(null);
}}
/>
<NewExpenseDialog open={newExpenseOpen} onOpenChange={setNewExpenseOpen} />
</div>
);
}

View File

@@ -0,0 +1,142 @@
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 } from "lucide-react";
interface ExpensesTableProps {
data: Expense[];
onPay: (expense: Expense) => void;
}
export function ExpensesTable({ data, onPay }: 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>
);
},
},
{
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(),
});
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 para mostrar.
</td>
</tr>
)}
</tbody>
</table>
</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,132 @@
import { useState } from "react";
import { useExpensesContext } from "../ExpensesProvider";
import { PeriodicExpensesTable } from "./PeriodicExpensesTable";
import { PeriodicExpenseForm } from "./PeriodicExpenseForm";
import { GenerateCurrentMonthDialog } from "./GenerateCurrentMonthDialog";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import type { PeriodicExpense } from "@/lib/api";
export function PeriodicExpensesTabContent() {
const {
periodicExpenses,
isLoadingPeriodic,
createPeriodicExpense,
updatePeriodicExpense,
deletePeriodicExpense,
generateMonthlyExpense,
} = 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 });
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>
<Button onClick={openCreate} size="sm">
<Plus className="size-4" />
Nuevo
</Button>
</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}
/>
</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(); 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[]> { export function getQuotes(): Promise<Quote[]> {
return fetcher<Quote[]>("/api/quotes"); return fetcher<Quote[]>("/api/quotes");
} }
@@ -80,3 +92,81 @@ export function getDailyQuotes(
const params = new URLSearchParams({ startDate, endDate }); const params = new URLSearchParams({ startDate, endDate });
return fetcher<DailyQuote[]>(`/api/quotes/${type}/daily?${params}`); 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 function getExpenses(status?: string): Promise<Expense[]> {
const params = status ? `?status=${status}` : "";
return fetcher<Expense[]>(`/api/expenses${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);
}

View File

@@ -1,5 +1,14 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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,
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
} from "./api";
export const quoteKeys = { export const quoteKeys = {
all: ["quotes"] as const, all: ["quotes"] as const,
@@ -57,3 +66,87 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
refetchInterval: 60_000, refetchInterval: 60_000,
}); });
} }
// Expenses
export const expenseKeys = {
periodic: ["periodic-expenses"] as const,
all: ["expenses"] as const,
byStatus: (status: string) => ["expenses", status] 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) {
return useQuery({
queryKey: expenseKeys.byStatus(status),
queryFn: () => getExpenses(status === "all" ? undefined : status),
});
}
export function useCreateNonPeriodicExpense() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNonPeriodicExpenseInput) => createNonPeriodicExpenseApi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: expenseKeys.all });
},
});
}
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.