feat/expenses-management #1
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
16
apps/backend/src/modules/expenses/expense.job.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { logger } from "../../lib/logger";
|
||||||
|
import { generateExpensesForCurrentMonth } from "./expenses.service";
|
||||||
|
|
||||||
|
export function startExpenseJob(): void {
|
||||||
|
cron.schedule("0 1 1 * *", async () => {
|
||||||
|
logger.info("Generating monthly expenses...");
|
||||||
|
try {
|
||||||
|
const results = await generateExpensesForCurrentMonth();
|
||||||
|
logger.info({ results }, "Monthly expenses generated");
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "Failed to generate monthly expenses");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info("Expense cron scheduled: 0 1 1 * *");
|
||||||
|
}
|
||||||
33
apps/backend/src/modules/expenses/expenses.routes.ts
Normal file
33
apps/backend/src/modules/expenses/expenses.routes.ts
Normal 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;
|
||||||
214
apps/backend/src/modules/expenses/expenses.service.ts
Normal file
214
apps/backend/src/modules/expenses/expenses.service.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { generateMonthlyExpense } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function generateMonthlyExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const result = await generateMonthlyExpense(id);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal file
121
apps/backend/src/modules/expenses/handlers/importExpenses.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
18
apps/backend/src/modules/expenses/handlers/listExpenses.ts
Normal file
18
apps/backend/src/modules/expenses/handlers/listExpenses.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { listPeriodicExpenses } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function listPeriodicExpensesHandler(c: Context) {
|
||||||
|
const expenses = await listPeriodicExpenses();
|
||||||
|
return c.json(expenses);
|
||||||
|
}
|
||||||
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
10
apps/backend/src/modules/expenses/handlers/payExpense.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { payExpense, payExpenseSchema } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function payExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = payExpenseSchema.parse(body);
|
||||||
|
const result = await payExpense(id, parsed);
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Context } from "hono";
|
||||||
|
import { softDeletePeriodicExpense } from "../expenses.service";
|
||||||
|
|
||||||
|
export async function softDeletePeriodicExpenseHandler(c: Context) {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
await softDeletePeriodicExpense(id);
|
||||||
|
return c.json({ success: true });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
496
apps/backend/tests/expenses.test.ts
Normal file
496
apps/backend/tests/expenses.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
6
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
6
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
@@ -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";
|
||||||
|
|||||||
142
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
142
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
8051
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
8051
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
File diff suppressed because it is too large
Load Diff
8
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
8
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
File diff suppressed because one or more lines are too long
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
6
apps/frontend/node_modules/.vite/deps/recharts.js
generated
vendored
@@ -2,15 +2,15 @@ import {
|
|||||||
require_with_selector
|
require_with_selector
|
||||||
} from "./chunk-NMJBVCD2.js";
|
} from "./chunk-NMJBVCD2.js";
|
||||||
import "./chunk-X37QSMNJ.js";
|
import "./chunk-X37QSMNJ.js";
|
||||||
|
import {
|
||||||
|
clsx
|
||||||
|
} from "./chunk-WFNHCR67.js";
|
||||||
import {
|
import {
|
||||||
require_react_dom
|
require_react_dom
|
||||||
} from "./chunk-JCMTJHXH.js";
|
} from "./chunk-JCMTJHXH.js";
|
||||||
import {
|
import {
|
||||||
require_react
|
require_react
|
||||||
} from "./chunk-6VO5C3OI.js";
|
} from "./chunk-6VO5C3OI.js";
|
||||||
import {
|
|
||||||
clsx
|
|
||||||
} from "./chunk-WFNHCR67.js";
|
|
||||||
import {
|
import {
|
||||||
__commonJS,
|
__commonJS,
|
||||||
__export,
|
__export,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
87
apps/frontend/src/components/ui/calendar.tsx
Normal file
87
apps/frontend/src/components/ui/calendar.tsx
Normal 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 }
|
||||||
62
apps/frontend/src/components/ui/date-picker.tsx
Normal file
62
apps/frontend/src/components/ui/date-picker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/frontend/src/components/ui/input.tsx
Normal file
19
apps/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * 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 }
|
||||||
129
apps/frontend/src/components/ui/pagination.tsx
Normal file
129
apps/frontend/src/components/ui/pagination.tsx
Normal 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,
|
||||||
|
}
|
||||||
48
apps/frontend/src/components/ui/popover.tsx
Normal file
48
apps/frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as 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 }
|
||||||
225
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal file
225
apps/frontend/src/components/ui/responsive-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
190
apps/frontend/src/components/ui/select.tsx
Normal file
190
apps/frontend/src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
import { Minus, RefreshCw, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
import { RelativeTime } from "@/lib/time";
|
import { RelativeTime } from "@/lib/time";
|
||||||
import type { Quote } from "@/lib/api";
|
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 { useNavigate } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -101,6 +101,9 @@ export function DashboardPage() {
|
|||||||
const belo = quotes?.find((q) => q.type === "BELO");
|
const belo = quotes?.find((q) => q.type === "BELO");
|
||||||
const blue = quotes?.find((q) => q.type === "BLUE");
|
const blue = quotes?.find((q) => q.type === "BLUE");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const { data: monthlyExpenses } = useMonthlyPayedTotal(now.getFullYear(), now.getMonth() + 1);
|
||||||
|
|
||||||
const isFetching = isLoading || isPending;
|
const isFetching = isLoading || isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +127,7 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<p className="text-sm text-muted-foreground">Gastos del mes</p>
|
<p className="text-sm text-muted-foreground">Gastos del mes</p>
|
||||||
<p className="text-2xl font-bold">$0.00</p>
|
<p className="text-2xl font-bold">${priceFormatter.format(monthlyExpenses?.total ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
|
<QuoteCard quote={belo} title="BELO" to="/quotes/belo" variant="minimal" />
|
||||||
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
<QuoteCard quote={blue} title="BLUE" variant="minimal" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
198
apps/frontend/src/features/expenses/ExpensesProvider.tsx
Normal file
198
apps/frontend/src/features/expenses/ExpensesProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal file
257
apps/frontend/src/features/expenses/components/ExpensesTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
|
Sí, generar
|
||||||
|
</Button>
|
||||||
|
</ResponsiveDialogFooter>
|
||||||
|
</ResponsiveDialogContent>
|
||||||
|
</ResponsiveDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,163 @@ 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 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
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,
|
||||||
|
getMonthlyPayedTotal,
|
||||||
|
getMonthlyTotals,
|
||||||
|
getTotalPending,
|
||||||
|
importPeriodicExpenses,
|
||||||
|
importExpenses,
|
||||||
|
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
export const quoteKeys = {
|
export const quoteKeys = {
|
||||||
all: ["quotes"] as const,
|
all: ["quotes"] as const,
|
||||||
@@ -57,3 +71,138 @@ 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,
|
||||||
|
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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user