feat(expenses): add monthly totals and pagination support for expenses

This commit is contained in:
Jose Selesan
2026-05-29 10:59:40 -03:00
parent e1786f7384
commit 4b17940134
12 changed files with 410 additions and 57 deletions

View File

@@ -7,6 +7,8 @@ import { generateMonthlyExpenseHandler } from "./handlers/generateMonthlyExpense
import { listExpensesHandler } from "./handlers/listExpenses"; import { listExpensesHandler } from "./handlers/listExpenses";
import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense"; import { createNonPeriodicExpenseHandler } from "./handlers/createNonPeriodicExpense";
import { payExpenseHandler } from "./handlers/payExpense"; import { payExpenseHandler } from "./handlers/payExpense";
import { getMonthlyPayedTotalHandler } from "./handlers/getMonthlyPayedTotal";
import { getMonthlyTotalsHandler } from "./handlers/getMonthlyTotals";
const app = new Hono(); const app = new Hono();
@@ -16,6 +18,8 @@ app.put("/periodic-expenses/:id", updatePeriodicExpenseHandler);
app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler); app.delete("/periodic-expenses/:id", softDeletePeriodicExpenseHandler);
app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler); app.post("/periodic-expenses/:id/generate-month", generateMonthlyExpenseHandler);
app.get("/expenses/monthly-total", getMonthlyPayedTotalHandler);
app.get("/expenses/totals", getMonthlyTotalsHandler);
app.get("/expenses", listExpensesHandler); app.get("/expenses", listExpensesHandler);
app.post("/expenses", createNonPeriodicExpenseHandler); app.post("/expenses", createNonPeriodicExpenseHandler);
app.put("/expenses/:id/pay", payExpenseHandler); app.put("/expenses/:id/pay", payExpenseHandler);

View File

@@ -84,16 +84,27 @@ export async function generateMonthlyExpense(id: number) {
}); });
} }
export async function listExpenses(status?: string) { export async function listExpenses(params: {
status?: string;
page?: number;
pageSize?: number;
}) {
const { status, page = 1, pageSize = 10 } = params;
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (status === "PENDING" || status === "PAYED") { if (status === "PENDING" || status === "PAYED") {
where.status = status; where.status = status;
} }
return prisma.expense.findMany({ const [data, total] = await Promise.all([
prisma.expense.findMany({
where, where,
include: { periodicExpense: true }, include: { periodicExpense: true },
orderBy: { dueDate: "asc" }, 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>) { export async function createNonPeriodicExpense(data: z.infer<typeof createNonPeriodicExpenseSchema>) {
@@ -126,6 +137,32 @@ export async function payExpense(id: number, data: z.infer<typeof payExpenseSche
}); });
} }
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 generateExpensesForCurrentMonth() { export async function generateExpensesForCurrentMonth() {
const { year, month } = getCurrentYearMonthUTC(); const { year, month } = getCurrentYearMonthUTC();

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import { listExpenses } from "../expenses.service";
export async function listExpensesHandler(c: Context) { export async function listExpensesHandler(c: Context) {
const status = c.req.query("status"); const status = c.req.query("status");
const expenses = await listExpenses(status); const page = parseInt(c.req.query("page") ?? "1", 10);
return c.json(expenses); const pageSize = parseInt(c.req.query("pageSize") ?? "10", 10);
const result = await listExpenses({ status, page, pageSize });
return c.json(result);
} }

View File

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

View File

@@ -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" />

View File

@@ -8,6 +8,7 @@ import {
useExpenses, useExpenses,
useCreateNonPeriodicExpense, useCreateNonPeriodicExpense,
usePayExpense, usePayExpense,
useMonthlyTotals,
} from "@/lib/queries"; } from "@/lib/queries";
import type { import type {
PeriodicExpense, PeriodicExpense,
@@ -15,16 +16,24 @@ import type {
CreatePeriodicExpenseInput, CreatePeriodicExpenseInput,
CreateNonPeriodicExpenseInput, CreateNonPeriodicExpenseInput,
PayExpenseInput, PayExpenseInput,
PaginatedResponse,
MonthlyTotals,
} from "@/lib/api"; } from "@/lib/api";
interface ExpensesContextValue { interface ExpensesContextValue {
periodicExpenses: PeriodicExpense[]; periodicExpenses: PeriodicExpense[];
isLoadingPeriodic: boolean; isLoadingPeriodic: boolean;
expenses: Expense[]; expenses: PaginatedResponse<Expense>;
isLoadingExpenses: boolean; isLoadingExpenses: boolean;
statusFilter: string; statusFilter: string;
setStatusFilter: (status: string) => void; setStatusFilter: (status: string) => void;
page: number;
setPage: (page: number) => void;
pageSize: number;
monthlyTotals: MonthlyTotals | undefined;
isLoadingTotals: boolean;
createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>; createPeriodicExpense: (data: CreatePeriodicExpenseInput) => Promise<PeriodicExpense>;
updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>; updatePeriodicExpense: (id: number, data: CreatePeriodicExpenseInput) => Promise<void>;
@@ -38,9 +47,15 @@ const ExpensesContext = createContext<ExpensesContextValue | null>(null);
export function ExpensesProvider({ children }: { children: ReactNode }) { export function ExpensesProvider({ children }: { children: ReactNode }) {
const [statusFilter, setStatusFilter] = useState("PENDING"); const [statusFilter, setStatusFilter] = useState("PENDING");
const [page, setPage] = useState(1);
const pageSize = 10;
const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses(); const { data: periodicExpenses = [], isLoading: isLoadingPeriodic } = usePeriodicExpenses();
const { data: expenses = [], isLoading: isLoadingExpenses } = useExpenses(statusFilter); const { data: expenses = { data: [], total: 0, page: 1, pageSize: 10 }, isLoading: isLoadingExpenses } =
useExpenses(statusFilter, page, pageSize);
const now = new Date();
const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1);
const createPeriodicMutation = useCreatePeriodicExpense(); const createPeriodicMutation = useCreatePeriodicExpense();
const updatePeriodicMutation = useUpdatePeriodicExpense(); const updatePeriodicMutation = useUpdatePeriodicExpense();
@@ -100,6 +115,11 @@ export function ExpensesProvider({ children }: { children: ReactNode }) {
isLoadingExpenses, isLoadingExpenses,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
page,
setPage,
pageSize,
monthlyTotals,
isLoadingTotals,
createPeriodicExpense, createPeriodicExpense,
updatePeriodicExpense: updatePeriodicExpenseFn, updatePeriodicExpense: updatePeriodicExpenseFn,
deletePeriodicExpense: deletePeriodicExpenseFn, deletePeriodicExpense: deletePeriodicExpenseFn,

View File

@@ -7,8 +7,23 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import type { Expense } from "@/lib/api"; import type { Expense } from "@/lib/api";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export function ExpensesTabContent() { export function ExpensesTabContent() {
const { expenses, isLoadingExpenses, statusFilter, setStatusFilter } = useExpensesContext(); const {
expenses,
isLoadingExpenses,
statusFilter,
setStatusFilter,
page,
setPage,
pageSize,
monthlyTotals,
isLoadingTotals,
} = useExpensesContext();
const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null); const [payDialogExpense, setPayDialogExpense] = useState<Expense | null>(null);
const [newExpenseOpen, setNewExpenseOpen] = useState(false); const [newExpenseOpen, setNewExpenseOpen] = useState(false);
@@ -28,7 +43,7 @@ export function ExpensesTabContent() {
key={f.value} key={f.value}
variant={statusFilter === f.value ? "default" : "outline"} variant={statusFilter === f.value ? "default" : "outline"}
size="xs" size="xs"
onClick={() => setStatusFilter(f.value)} onClick={() => { setStatusFilter(f.value); setPage(1); }}
> >
{f.label} {f.label}
</Button> </Button>
@@ -43,9 +58,35 @@ export function ExpensesTabContent() {
{isLoadingExpenses ? ( {isLoadingExpenses ? (
<p className="text-sm text-muted-foreground">Cargando...</p> <p className="text-sm text-muted-foreground">Cargando...</p>
) : ( ) : (
<ExpensesTable data={expenses} onPay={setPayDialogExpense} /> <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-2">
<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</p>
<p className="text-2xl font-bold tabular-nums">
{isLoadingTotals
? "..."
: `$${priceFormatter.format(monthlyTotals?.pending ?? 0)}`}
</p>
</div>
</div>
<PayExpenseDialog <PayExpenseDialog
expense={payDialogExpense} expense={payDialogExpense}
open={payDialogExpense !== null} open={payDialogExpense !== null}

View File

@@ -8,13 +8,25 @@ import {
import type { Expense } from "@/lib/api"; import type { Expense } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CircleDollarSign } from "lucide-react"; import { CircleDollarSign } from "lucide-react";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface ExpensesTableProps { interface ExpensesTableProps {
data: Expense[]; data: Expense[];
onPay: (expense: Expense) => void; onPay: (expense: Expense) => void;
page: number;
total: number;
pageSize: number;
onPageChange: (page: number) => void;
} }
export function ExpensesTable({ data, onPay }: ExpensesTableProps) { export function ExpensesTable({ data, onPay, page, total, pageSize, onPageChange }: ExpensesTableProps) {
const columns = useMemo<ColumnDef<Expense>[]>( const columns = useMemo<ColumnDef<Expense>[]>(
() => [ () => [
{ {
@@ -101,7 +113,10 @@ export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return ( return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -138,5 +153,38 @@ export function ExpensesTable({ data, onPay }: ExpensesTableProps) {
</tbody> </tbody>
</table> </table>
</div> </div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={(e) => { e.preventDefault(); if (page > 1) onPageChange(page - 1); }}
href="#"
className={page <= 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<PaginationItem key={p}>
<PaginationLink
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPageChange(p); }}
href="#"
>
{p}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPageChange(page + 1); }}
href="#"
className={page >= totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
); );
} }

View File

@@ -158,9 +158,41 @@ export function generateMonthlyExpense(id: number): Promise<Expense> {
return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST"); return mutator<Expense>(`/api/periodic-expenses/${id}/generate-month`, "POST");
} }
export function getExpenses(status?: string): Promise<Expense[]> { export type MonthlyExpensesTotal = { total: number };
const params = status ? `?status=${status}` : "";
return fetcher<Expense[]>(`/api/expenses${params}`); 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): 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));
const qs = params.toString();
return fetcher<PaginatedResponse<Expense>>(`/api/expenses${qs ? `?${qs}` : ""}`);
}
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> { export function createNonPeriodicExpense(data: CreateNonPeriodicExpenseInput): Promise<Expense> {

View File

@@ -7,6 +7,8 @@ import {
generateMonthlyExpense as generateMonthlyExpenseApi, generateMonthlyExpense as generateMonthlyExpenseApi,
getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi, getExpenses, createNonPeriodicExpense as createNonPeriodicExpenseApi,
payExpense as payExpenseApi, payExpense as payExpenseApi,
getMonthlyPayedTotal,
getMonthlyTotals,
type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput, type CreatePeriodicExpenseInput, type CreateNonPeriodicExpenseInput, type PayExpenseInput,
} from "./api"; } from "./api";
@@ -72,7 +74,10 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
export const expenseKeys = { export const expenseKeys = {
periodic: ["periodic-expenses"] as const, periodic: ["periodic-expenses"] as const,
all: ["expenses"] as const, all: ["expenses"] as const,
byStatus: (status: string) => ["expenses", status] as const, byStatus: (status: string, page: number, pageSize: number) =>
["expenses", status, page, pageSize] 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,
}; };
export function usePeriodicExpenses() { export function usePeriodicExpenses() {
@@ -123,10 +128,24 @@ export function useGenerateMonthlyExpense() {
}); });
} }
export function useExpenses(status: string) { export function useExpenses(status: string, page: number = 1, pageSize: number = 10) {
return useQuery({ return useQuery({
queryKey: expenseKeys.byStatus(status), queryKey: expenseKeys.byStatus(status, page, pageSize),
queryFn: () => getExpenses(status === "all" ? undefined : status), queryFn: () => getExpenses(status === "all" ? undefined : status, page, pageSize),
});
}
export function useMonthlyTotals(year: number, month: number) {
return useQuery({
queryKey: expenseKeys.monthlyTotals(year, month),
queryFn: () => getMonthlyTotals(year, month),
});
}
export function useMonthlyPayedTotal(year: number, month: number) {
return useQuery({
queryKey: expenseKeys.monthlyTotal(year, month),
queryFn: () => getMonthlyPayedTotal(year, month),
}); });
} }