feat(expenses): implement responsive dialog for expense management and create expense provider

- Added ResponsiveDialog component for handling responsive dialogs in the UI.
- Created ExpensesProvider to manage state and API interactions for expenses.
- Developed ExpensesTabContent to display expenses with filtering options and dialogs for new and pay expense actions.
- Implemented ExpensesTable for rendering expense data in a tabular format with actions.
- Added dialogs for creating new expenses and paying existing ones with form validation.
- Introduced PeriodicExpenseForm for managing periodic expenses with month selection.
- Created PeriodicExpensesTabContent to manage and display periodic expenses with create/edit functionality.
- Added GenerateCurrentMonthDialog for confirming monthly expense generation.
- Implemented PeriodicExpensesTable for displaying periodic expenses with edit and delete actions.
This commit is contained in:
Jose Selesan
2026-05-29 10:29:20 -03:00
parent bd5e29236b
commit e1786f7384
39 changed files with 4338 additions and 6435 deletions

View File

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