diff --git a/.gitignore b/.gitignore index dc6c862..d60bda2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules dist build .vite +.tanstack \ No newline at end of file diff --git a/apps/backend/src/modules/expenses/expenses.routes.ts b/apps/backend/src/modules/expenses/expenses.routes.ts index fe5daa6..8b45589 100644 --- a/apps/backend/src/modules/expenses/expenses.routes.ts +++ b/apps/backend/src/modules/expenses/expenses.routes.ts @@ -10,6 +10,7 @@ import { payExpenseHandler } from "./handlers/payExpense"; import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal"; import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals"; import { getTotalPendingHandler } from "./handlers/getTotalPending"; +import { getPendingUpcomingExpensesHandler } from "./handlers/getPendingUpcomingExpenses"; import { importPeriodicExpensesHandler } from "./handlers/importPeriodicExpenses"; import { importExpensesHandler } from "./handlers/importExpenses"; @@ -24,6 +25,7 @@ app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler) app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler); app.get("/expenses/totals", getMonthlyTotalsHandler); +app.get("/expenses/pending-upcoming", getPendingUpcomingExpensesHandler); app.get("/expenses/pending-total", getTotalPendingHandler); app.get("/expenses", listExpensesHandler); app.post("/expenses", createNonPeriodicExpenseHandler); diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 617df9e..9a077c7 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -216,6 +216,27 @@ export async function getTotalPending() { return data; } +export async function listPendingUpcomingExpenses() { + const now = new Date(); + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + const fiveDaysLater = new Date(today); + fiveDaysLater.setUTCDate(fiveDaysLater.getUTCDate() + 5); + + const data = await prisma.expense.findMany({ + where: { + status: PaymentStatus.PENDING, + dueDate: { lte: fiveDaysLater }, + }, + include: { periodicExpense: true }, + orderBy: { dueDate: "asc" }, + }); + + return data.map((expense) => ({ + ...expense, + dueType: expense.dueDate < today ? "overdue" as const : "upcoming" as const, + })); +} + export async function generateExpensesForCurrentMonth() { const { year, month } = getCurrentYearMonthUTC(); diff --git a/apps/backend/src/modules/expenses/handlers/getPendingUpcomingExpenses.ts b/apps/backend/src/modules/expenses/handlers/getPendingUpcomingExpenses.ts new file mode 100644 index 0000000..8addaad --- /dev/null +++ b/apps/backend/src/modules/expenses/handlers/getPendingUpcomingExpenses.ts @@ -0,0 +1,7 @@ +import type { Context } from "hono"; +import { listPendingUpcomingExpenses } from "../expenses.service"; + +export async function getPendingUpcomingExpensesHandler(c: Context) { + const result = await listPendingUpcomingExpenses(); + return c.json(result); +} diff --git a/apps/frontend/src/features/belo/BeloAnalysisPage.tsx b/apps/frontend/src/features/belo/BeloAnalysisPage.tsx index d0dc768..9c2ba99 100644 --- a/apps/frontend/src/features/belo/BeloAnalysisPage.tsx +++ b/apps/frontend/src/features/belo/BeloAnalysisPage.tsx @@ -133,11 +133,11 @@ export function BeloAnalysisPage() {
- + d && setStartDate(d)} placeholder="Fecha inicio" />
- + d && setEndDate(d)} placeholder="Fecha fin" />
diff --git a/apps/frontend/src/features/dashboard/DashboardPage.tsx b/apps/frontend/src/features/dashboard/DashboardPage.tsx index 1a6f968..26eb42d 100644 --- a/apps/frontend/src/features/dashboard/DashboardPage.tsx +++ b/apps/frontend/src/features/dashboard/DashboardPage.tsx @@ -9,6 +9,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { DashboardExpensesTable } from "./components/DashboardExpensesTable"; const priceFormatter = new Intl.NumberFormat("es-AR", { minimumFractionDigits: 2, @@ -133,6 +134,7 @@ export function DashboardPage() { + ); } diff --git a/apps/frontend/src/features/dashboard/components/DashboardExpensesTable.tsx b/apps/frontend/src/features/dashboard/components/DashboardExpensesTable.tsx new file mode 100644 index 0000000..0ca6775 --- /dev/null +++ b/apps/frontend/src/features/dashboard/components/DashboardExpensesTable.tsx @@ -0,0 +1,208 @@ +import { useMemo } from "react"; +import { CalendarClock, ExternalLink } from "lucide-react"; +import { useNavigate } from "@tanstack/react-router"; +import { usePendingUpcomingExpenses } from "@/lib/queries"; +import type { PendingUpcomingExpense } from "@/lib/api"; + +type GroupedExpense = { + periodicExpenseId: number; + description: string; + totalAmount: number; + earliestDueDate: string; + dueType: "overdue" | "upcoming"; + count: number; +}; + +function groupExpenses(expenses: PendingUpcomingExpense[]): GroupedExpense[] { + const map = new Map(); + + for (const e of expenses) { + if (e.periodicExpenseId === null) continue; + + const key = e.periodicExpenseId; + const existing = map.get(key); + + if (existing) { + existing.totalAmount += Number(e.amount); + existing.count += 1; + if (e.dueDate < existing.earliestDueDate) { + existing.earliestDueDate = e.dueDate; + } + if (e.dueType === "overdue") { + existing.dueType = "overdue"; + } + } else { + map.set(key, { + periodicExpenseId: key, + description: e.periodicExpense?.description ?? e.description, + totalAmount: Number(e.amount), + earliestDueDate: e.dueDate, + dueType: e.dueType, + count: 1, + }); + } + } + + return Array.from(map.values()).sort((a, b) => { + if (a.dueType !== b.dueType) return a.dueType === "overdue" ? -1 : 1; + return a.earliestDueDate.localeCompare(b.earliestDueDate); + }); +} + +function formatDate(value: string) { + return new Date(value).toLocaleDateString("es-AR", { + timeZone: "UTC", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function formatAmount(value: number) { + return `$${value.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`; +} + +function DueTypeBadge({ dueType }: { dueType: "overdue" | "upcoming" }) { + return ( + + {dueType === "overdue" ? "Vencido" : "Próximo"} + + ); +} + +export function DashboardExpensesTable() { + const { data: expenses, isLoading } = usePendingUpcomingExpenses(); + const navigate = useNavigate(); + + const groups = useMemo(() => { + if (!expenses) return []; + return groupExpenses(expenses); + }, [expenses]); + + function goToExpenses(periodicExpenseId: number) { + navigate({ to: "/expenses", search: { periodicExpenseId } }); + } + + const overdueCount = groups.filter((g) => g.dueType === "overdue").length; + const upcomingCount = groups.filter((g) => g.dueType === "upcoming").length; + const grandTotal = groups.reduce((sum, g) => sum + g.totalAmount, 0); + const hasData = groups.length > 0; + + return ( +
+
+ +

Gastos por vencer

+ {isLoading && ( + Cargando... + )} + {!isLoading && hasData && ( + + {overdueCount} vencido{overdueCount !== 1 ? "s" : ""} + {upcomingCount > 0 && ` · ${upcomingCount} próximo${upcomingCount !== 1 ? "s" : ""}`} + + )} +
+ + {!isLoading && !hasData && ( +
+ No hay gastos pendientes. +
+ )} + + {isLoading && ( +
+ Cargando... +
+ )} + +
+ {groups.map((group) => ( +
+
+
+ +

+ Vence {formatDate(group.earliestDueDate)} + {group.count > 1 && ` (${group.count} gastos)`} +

+
+ +
+ +

+ {formatAmount(group.totalAmount)} +

+
+ ))} + {hasData && ( +
+ Total + {formatAmount(grandTotal)} +
+ )} +
+ +
+ + + + + + + + + + + + {groups.map((group) => ( + + + + + + + + ))} + {hasData && ( + + + + + )} + +
DescripciónMontoVencimientoCantidadEstado
+ + {formatAmount(group.totalAmount)}{formatDate(group.earliestDueDate)} + {group.count > 1 ? `${group.count} gastos` : "1 gasto"} + + +
Total{formatAmount(grandTotal)} +
+
+
+ ); +} diff --git a/apps/frontend/src/features/expenses/ExpensesPage.tsx b/apps/frontend/src/features/expenses/ExpensesPage.tsx index d2a5b04..6e67e17 100644 --- a/apps/frontend/src/features/expenses/ExpensesPage.tsx +++ b/apps/frontend/src/features/expenses/ExpensesPage.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Route } from "@/routes/_authenticated/expenses"; import { ExpensesProvider } from "./ExpensesProvider"; import { ExpensesTabContent } from "./components/ExpensesTabContent"; import { PeriodicExpensesTabContent } from "./components/PeriodicExpensesTabContent"; @@ -41,8 +42,9 @@ function ExpensesPageInner() { } export function ExpensesPage() { + const { periodicExpenseId } = Route.useSearch(); return ( - + ); diff --git a/apps/frontend/src/features/expenses/ExpensesProvider.tsx b/apps/frontend/src/features/expenses/ExpensesProvider.tsx index 4f0949c..28886a6 100644 --- a/apps/frontend/src/features/expenses/ExpensesProvider.tsx +++ b/apps/frontend/src/features/expenses/ExpensesProvider.tsx @@ -59,11 +59,11 @@ interface ExpensesContextValue { const ExpensesContext = createContext(null); -export function ExpensesProvider({ children }: { children: ReactNode }) { +export function ExpensesProvider({ children, initialPeriodicExpenseId }: { children: ReactNode; initialPeriodicExpenseId?: number }) { const [statusFilter, setStatusFilter] = useState("PENDING"); const [page, setPage] = useState(1); const pageSize = 10; - const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState(undefined); + const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState(initialPeriodicExpenseId); const [searchInput, setSearchInput] = useState(""); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 02a45ea..947f5ee 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -208,6 +208,14 @@ export function getExpenses( return fetcher>(`/api/expenses${qs ? `?${qs}` : ""}`); } +export type PendingUpcomingExpense = Expense & { + dueType: "overdue" | "upcoming"; +}; + +export function getPendingUpcomingExpenses(): Promise { + return fetcher("/api/expenses/pending-upcoming"); +} + export function getTotalPending(): Promise { return fetcher("/api/expenses/pending-total"); } diff --git a/apps/frontend/src/lib/queries.ts b/apps/frontend/src/lib/queries.ts index 8d59bfc..af09360 100644 --- a/apps/frontend/src/lib/queries.ts +++ b/apps/frontend/src/lib/queries.ts @@ -12,6 +12,7 @@ import { getTotalPending, importPeriodicExpenses, importExpenses, + getPendingUpcomingExpenses, type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput, } from "./api"; @@ -92,6 +93,7 @@ export const expenseKeys = { monthlyTotal: (year: number, month: number) => ["expenses", "monthly-total", year, month] as const, monthlyTotals: (year: number, month: number) => ["expenses", "totals", year, month] as const, totalPending: ["expenses", "pending-total"] as const, + pendingUpcoming: ["expenses", "pending-upcoming"] as const, }; export function usePeriodicExpenses() { @@ -163,6 +165,13 @@ export function useMonthlyTotals(year: number, month: number) { }); } +export function usePendingUpcomingExpenses() { + return useQuery({ + queryKey: expenseKeys.pendingUpcoming, + queryFn: getPendingUpcomingExpenses, + }); +} + export function useTotalPending() { return useQuery({ queryKey: expenseKeys.totalPending, diff --git a/apps/frontend/src/routes/_authenticated/expenses.tsx b/apps/frontend/src/routes/_authenticated/expenses.tsx index 7deb746..4e28ffd 100644 --- a/apps/frontend/src/routes/_authenticated/expenses.tsx +++ b/apps/frontend/src/routes/_authenticated/expenses.tsx @@ -1,6 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { ExpensesPage } from "@/features/expenses/ExpensesPage"; export const Route = createFileRoute("/_authenticated/expenses")({ + validateSearch: z.object({ + periodicExpenseId: z.coerce.number().optional(), + }), component: ExpensesPage, });