feat(expenses): enhance expenses management with filtering and search capabilities
This commit is contained in:
@@ -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<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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 }
|
||||
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,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<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);
|
||||
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,
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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
|
||||
|
||||
@@ -179,11 +179,19 @@ export type MonthlyTotals = {
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export function getExpenses(status?: string, page?: number, pageSize?: number): Promise<PaginatedResponse<Expense>> {
|
||||
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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ export function useDailyQuotes(type: string, startDate: string, endDate: string)
|
||||
export const expenseKeys = {
|
||||
periodic: ["periodic-expenses"] as const,
|
||||
all: ["expenses"] as const,
|
||||
byStatus: (status: string, page: number, pageSize: number) =>
|
||||
["expenses", status, page, pageSize] 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,
|
||||
@@ -132,10 +132,16 @@ export function useGenerateMonthlyExpense() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useExpenses(status: string, page: number = 1, pageSize: number = 10) {
|
||||
export function useExpenses(status: string, page: number = 1, pageSize: number = 10, periodicExpenseId?: number, search?: string) {
|
||||
return useQuery({
|
||||
queryKey: expenseKeys.byStatus(status, page, pageSize),
|
||||
queryFn: () => getExpenses(status === "all" ? undefined : status, page, pageSize),
|
||||
queryKey: expenseKeys.byFilters(status, page, pageSize, periodicExpenseId, search),
|
||||
queryFn: () => getExpenses(
|
||||
status === "all" ? undefined : status,
|
||||
page,
|
||||
pageSize,
|
||||
periodicExpenseId,
|
||||
search,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user