From c9239c4f3c1958ded1ce8fabb37000207f2651d3 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Sun, 10 May 2026 13:51:30 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20filtro=20por=20faixa=20de=20valor=20e?= =?UTF-8?q?=20bot=C3=A3o=20limpar=20em=20lan=C3=A7amentos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Novo filtro mín/máx de valor no sheet de filtros, com debounce (400ms) e persistência via query string (amountMin/amountMax). Constantes AMOUNT_MIN_PARAM e AMOUNT_MAX_PARAM extraídas para constants.ts; parsePositiveAmount exportado de page-helpers e reutilizado pelo useDebouncedAmountFilter. A comparação do debounce usa o valor normalizado para evitar roundtrips RSC desnecessários. Botão 'Limpar' discreto ao lado do botão 'Filtros', visível apenas quando há filtros ativos. Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/payers/[payerId]/page.tsx | 2 + .../transactions/actions/export-actions.ts | 2 + .../components/table/transactions-filters.tsx | 107 +++++++++++++++++- src/features/transactions/lib/constants.ts | 3 + src/features/transactions/lib/export-types.ts | 2 + src/features/transactions/lib/page-helpers.ts | 41 ++++++- 6 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/payers/[payerId]/page.tsx b/src/app/(dashboard)/payers/[payerId]/page.tsx index 540902d..f9e88aa 100644 --- a/src/app/(dashboard)/payers/[payerId]/page.tsx +++ b/src/app/(dashboard)/payers/[payerId]/page.tsx @@ -85,6 +85,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = { settledFilter: null, attachmentFilter: null, dividedFilter: null, + amountMinFilter: null, + amountMaxFilter: null, }; const createEmptySlugMaps = (): SlugMaps => ({ diff --git a/src/features/transactions/actions/export-actions.ts b/src/features/transactions/actions/export-actions.ts index 37d01a8..914f203 100644 --- a/src/features/transactions/actions/export-actions.ts +++ b/src/features/transactions/actions/export-actions.ts @@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType = z.object( settledFilter: z.string().nullable(), attachmentFilter: z.string().nullable(), dividedFilter: z.string().nullable(), + amountMinFilter: z.number().nullable(), + amountMaxFilter: z.number().nullable(), }), accountId: z.string().min(1).nullable().optional(), cardId: z.string().min(1).nullable().optional(), diff --git a/src/features/transactions/components/table/transactions-filters.tsx b/src/features/transactions/components/table/transactions-filters.tsx index d06f2b2..8e76311 100644 --- a/src/features/transactions/components/table/transactions-filters.tsx +++ b/src/features/transactions/components/table/transactions-filters.tsx @@ -4,9 +4,14 @@ import { RiCheckLine, RiCloseLine, RiExpandUpDownLine, - RiFilter3Line, + RiFilterLine, } from "@remixicon/react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + type ReadonlyURLSearchParams, + usePathname, + useRouter, + useSearchParams, +} from "next/navigation"; import { type ReactNode, useCallback, @@ -16,11 +21,14 @@ import { useTransition, } from "react"; import { + AMOUNT_MAX_PARAM, + AMOUNT_MIN_PARAM, PAYMENT_METHODS, SETTLED_FILTER_VALUES, TRANSACTION_CONDITIONS, TRANSACTION_TYPES, } from "@/features/transactions/lib/constants"; +import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers"; import { Button } from "@/shared/components/ui/button"; import { Checkbox } from "@/shared/components/ui/checkbox"; import { @@ -70,6 +78,36 @@ import type { const FILTER_EMPTY_VALUE = "__all"; +const normalizeAmountParam = (raw: string): string | null => { + const parsed = parsePositiveAmount(raw.trim()); + return parsed === null ? null : parsed.toString(); +}; + +function useDebouncedAmountFilter( + param: string, + searchParams: URLSearchParams | ReadonlyURLSearchParams, + onChange: (key: string, value: string | null) => void, +): [string, (value: string) => void] { + const current = searchParams.get(param) ?? ""; + const [value, setValue] = useState(current); + + useEffect(() => { + setValue(current); + }, [current]); + + useEffect(() => { + if (value === current) return; + const timeout = setTimeout(() => { + const normalized = normalizeAmountParam(value); + if ((normalized ?? "") === current) return; + onChange(param, normalized); + }, 400); + return () => clearTimeout(timeout); + }, [value, current, param, onChange]); + + return [value, setValue]; +} + interface FilterSelectProps { param: string; placeholder: string; @@ -348,6 +386,7 @@ export function TransactionsFilters({ ? `${pathname}?${nextParams.toString()}` : pathname; router.replace(target, { scroll: false }); + router.refresh(); }); }, [searchParams, pathname, router], @@ -373,6 +412,17 @@ export function TransactionsFilters({ return () => clearTimeout(timeout); }, [searchValue, currentSearchParam, handleFilterChange]); + const [valorMinValue, setValorMinValue] = useDebouncedAmountFilter( + AMOUNT_MIN_PARAM, + searchParams, + handleFilterChange, + ); + const [valorMaxValue, setValorMaxValue] = useDebouncedAmountFilter( + AMOUNT_MAX_PARAM, + searchParams, + handleFilterChange, + ); + const handleReset = () => { const periodValue = searchParams.get("periodo"); const pageSizeValue = searchParams.get("pageSize"); @@ -384,6 +434,8 @@ export function TransactionsFilters({ nextParams.set("pageSize", pageSizeValue); } setSearchValue(""); + setValorMinValue(""); + setValorMaxValue(""); startTransition(() => { const target = nextParams.toString() ? `${pathname}?${nextParams.toString()}` @@ -467,7 +519,9 @@ export function TransactionsFilters({ searchParams.getAll("accountCard").length > 0 || searchParams.get("settled") || searchParams.get("hasAttachment") || - searchParams.get("isDivided"); + searchParams.get("isDivided") || + searchParams.get(AMOUNT_MIN_PARAM) || + searchParams.get(AMOUNT_MAX_PARAM); const handleResetFilters = () => { handleReset(); @@ -523,13 +577,27 @@ export function TransactionsFilters({ className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent" aria-label="Abrir filtros" > - + Filtros {hasActiveFilters && ( )} + {hasActiveFilters && ( + + )} Filtros @@ -636,6 +704,37 @@ export function TransactionsFilters({ /> +
+ +
+ setValorMinValue(event.target.value)} + disabled={isPending} + className="text-sm border-dashed" + /> + até + setValorMaxValue(event.target.value)} + disabled={isPending} + className="text-sm border-dashed" + /> +
+
+

Status

diff --git a/src/features/transactions/lib/constants.ts b/src/features/transactions/lib/constants.ts index 8c3fcd4..dc31685 100644 --- a/src/features/transactions/lib/constants.ts +++ b/src/features/transactions/lib/constants.ts @@ -30,3 +30,6 @@ export const SETTLED_FILTER_VALUES = { PAID: "pago", UNPAID: "nao-pago", } as const; + +export const AMOUNT_MIN_PARAM = "valorMin"; +export const AMOUNT_MAX_PARAM = "valorMax"; diff --git a/src/features/transactions/lib/export-types.ts b/src/features/transactions/lib/export-types.ts index c776f2b..d0ce0d9 100644 --- a/src/features/transactions/lib/export-types.ts +++ b/src/features/transactions/lib/export-types.ts @@ -9,6 +9,8 @@ type TransactionExportFilters = { settledFilter: string | null; attachmentFilter: string | null; dividedFilter: string | null; + amountMinFilter: number | null; + amountMaxFilter: number | null; }; export type TransactionsExportContext = { diff --git a/src/features/transactions/lib/page-helpers.ts b/src/features/transactions/lib/page-helpers.ts index ea9deb0..aea82cb 100644 --- a/src/features/transactions/lib/page-helpers.ts +++ b/src/features/transactions/lib/page-helpers.ts @@ -1,5 +1,15 @@ import type { SQL } from "drizzle-orm"; -import { and, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { + and, + eq, + gte, + ilike, + inArray, + isNotNull, + lte, + or, + sql, +} from "drizzle-orm"; import { cards, type categories, @@ -10,6 +20,8 @@ import { } from "@/db/schema"; import type { SelectOption } from "@/features/transactions/components/types"; import { + AMOUNT_MAX_PARAM, + AMOUNT_MIN_PARAM, PAYMENT_METHODS, SETTLED_FILTER_VALUES, TRANSACTION_CONDITIONS, @@ -46,6 +58,8 @@ export type TransactionSearchFilters = { settledFilter: string | null; attachmentFilter: string | null; dividedFilter: string | null; + amountMinFilter: number | null; + amountMaxFilter: number | null; }; type BaseSluggedOption = { @@ -135,6 +149,13 @@ export const getMultiParam = ( return list.filter((item): item is string => Boolean(item)); }; +export const parsePositiveAmount = (value: string | null): number | null => { + if (!value) return null; + const normalized = Number.parseFloat(value.replace(",", ".")); + if (!Number.isFinite(normalized) || normalized < 0) return null; + return Math.round(normalized * 100) / 100; +}; + export const extractTransactionSearchFilters = ( params: ResolvedSearchParams, ): TransactionSearchFilters => ({ @@ -148,6 +169,12 @@ export const extractTransactionSearchFilters = ( settledFilter: getSingleParam(params, "settled"), attachmentFilter: getSingleParam(params, "hasAttachment"), dividedFilter: getSingleParam(params, "isDivided"), + amountMinFilter: parsePositiveAmount( + getSingleParam(params, AMOUNT_MIN_PARAM), + ), + amountMaxFilter: parsePositiveAmount( + getSingleParam(params, AMOUNT_MAX_PARAM), + ), }); export const resolveTransactionPagination = ( @@ -442,6 +469,18 @@ export const buildTransactionWhere = ({ where.push(eq(transactions.isDivided, true)); } + if (filters.amountMinFilter !== null) { + where.push( + gte(sql`abs(${transactions.amount})`, filters.amountMinFilter.toFixed(2)), + ); + } + + if (filters.amountMaxFilter !== null) { + where.push( + lte(sql`abs(${transactions.amount})`, filters.amountMaxFilter.toFixed(2)), + ); + } + const searchPattern = buildSearchPattern(filters.searchFilter); if (searchPattern) { where.push(