diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index affb9ed..093dfb9 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -88,12 +88,20 @@ export async function listExpenses(params: { status?: string; page?: number; pageSize?: number; + periodicExpenseId?: number; + search?: string; }) { - const { status, page = 1, pageSize = 10 } = params; + const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params; const where: Record = {}; 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, diff --git a/apps/backend/src/modules/expenses/handlers/listExpenses.ts b/apps/backend/src/modules/expenses/handlers/listExpenses.ts index e077d5a..7b41d61 100644 --- a/apps/backend/src/modules/expenses/handlers/listExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/listExpenses.ts @@ -5,6 +5,14 @@ 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 result = await listExpenses({ status, page, pageSize }); + 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); } diff --git a/apps/backend/tests/expenses.test.ts b/apps/backend/tests/expenses.test.ts index fb59823..d84dde0 100644 --- a/apps/backend/tests/expenses.test.ts +++ b/apps/backend/tests/expenses.test.ts @@ -13,6 +13,7 @@ const mockPrisma = { findFirst: mock(), create: mock(), update: mock(), + count: mock(), }, }; @@ -26,6 +27,7 @@ beforeEach(() => { fn.mockClear(); } } + mockPrisma.expense.count.mockResolvedValue(0); }); const { @@ -229,7 +231,7 @@ describe("listPeriodicExpenses", () => { expect(result).toEqual(mockItems); expect(mockPrisma.periodicExpense.findMany).toHaveBeenCalledWith({ where: { isDeleted: false }, - orderBy: { createdAt: "desc" }, + orderBy: { description: "asc" }, }); }); }); @@ -289,21 +291,65 @@ describe("softDeletePeriodicExpense", () => { describe("listExpenses", () => { test("returns all expenses when no status filter", async () => { mockPrisma.expense.findMany.mockResolvedValueOnce([]); - await listExpenses(); + await listExpenses({}); expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({ where: {}, include: { periodicExpense: true }, - orderBy: { dueDate: "asc" }, + orderBy: { dueDate: "desc" }, + skip: 0, + take: 10, }); }); test("filters by status when provided", async () => { mockPrisma.expense.findMany.mockResolvedValueOnce([]); - await listExpenses("PENDING"); + await listExpenses({ status: "PENDING" }); expect(mockPrisma.expense.findMany).toHaveBeenCalledWith({ where: { status: "PENDING" }, include: { periodicExpense: true }, - orderBy: { dueDate: "asc" }, + 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, }); }); }); diff --git a/apps/frontend/src/components/ui/input.tsx b/apps/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..d763cd9 --- /dev/null +++ b/apps/frontend/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/apps/frontend/src/components/ui/select.tsx b/apps/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..8333850 --- /dev/null +++ b/apps/frontend/src/components/ui/select.tsx @@ -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) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/apps/frontend/src/features/expenses/ExpensesProvider.tsx b/apps/frontend/src/features/expenses/ExpensesProvider.tsx index 0ae0684..4f0949c 100644 --- a/apps/frontend/src/features/expenses/ExpensesProvider.tsx +++ b/apps/frontend/src/features/expenses/ExpensesProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from "react"; import { usePeriodicExpenses, useCreatePeriodicExpense, @@ -37,6 +37,10 @@ interface ExpensesContextValue { 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; @@ -59,10 +63,29 @@ export function ExpensesProvider({ children }: { children: ReactNode }) { const [statusFilter, setStatusFilter] = useState("PENDING"); const [page, setPage] = useState(1); const pageSize = 10; + const [periodicExpenseFilter, setPeriodicExpenseFilter] = useState(undefined); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + + const searchTimerRef = useRef>(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); + useExpenses(statusFilter, page, pageSize, periodicExpenseFilter, searchQuery || undefined); const now = new Date(); const { data: monthlyTotals, isLoading: isLoadingTotals } = useMonthlyTotals(now.getFullYear(), now.getMonth() + 1); @@ -145,6 +168,10 @@ export function ExpensesProvider({ children }: { children: ReactNode }) { page, setPage, pageSize, + periodicExpenseFilter, + setPeriodicExpenseFilter, + searchInput, + setSearchInput, monthlyTotals, isLoadingTotals, totalPending, diff --git a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx index 1fb0b58..0dfe558 100644 --- a/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx +++ b/apps/frontend/src/features/expenses/components/ExpensesTabContent.tsx @@ -11,7 +11,15 @@ import { ResponsiveDialogDescription, } from "@/components/ui/responsive-dialog"; import { Button } from "@/components/ui/button"; -import { Plus, Upload } from "lucide-react"; +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", { @@ -32,6 +40,11 @@ export function ExpensesTabContent() { isLoadingTotals, totalPending, isLoadingTotalPending, + periodicExpenses, + periodicExpenseFilter, + setPeriodicExpenseFilter, + searchInput, + setSearchInput, importExpenses, } = useExpensesContext(); @@ -64,7 +77,44 @@ export function ExpensesTabContent() { ))} -
+
+
+ + setSearchInput(e.target.value)} + className="pl-8 pr-8" + /> + {searchInput && ( + + )} +
+