feat: filtro por faixa de valor e botão limpar em lançamentos

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 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-05-10 13:51:30 +00:00
parent 7128cc0ae7
commit c9239c4f3c
6 changed files with 152 additions and 5 deletions

View File

@@ -85,6 +85,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
settledFilter: null,
attachmentFilter: null,
dividedFilter: null,
amountMinFilter: null,
amountMaxFilter: null,
};
const createEmptySlugMaps = (): SlugMaps => ({

View File

@@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = 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(),

View File

@@ -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"
>
<RiFilter3Line className="size-4" />
<RiFilterLine className="size-4" />
Filtros
{hasActiveFilters && (
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
)}
</Button>
</DrawerTrigger>
{hasActiveFilters && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleReset}
disabled={isPending}
aria-label="Limpar filtros"
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
>
<RiCloseLine className="size-3.5" />
Limpar
</Button>
)}
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filtros</DrawerTitle>
@@ -636,6 +704,37 @@ export function TransactionsFilters({
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Faixa de valor</label>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Mínimo"
aria-label="Valor mínimo"
value={valorMinValue}
onChange={(event) => setValorMinValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<Input
type="number"
inputMode="decimal"
min="0"
step="0.01"
placeholder="Máximo"
aria-label="Valor máximo"
value={valorMaxValue}
onChange={(event) => setValorMaxValue(event.target.value)}
disabled={isPending}
className="text-sm border-dashed"
/>
</div>
</div>
<div className="space-y-3">
<p className="text-sm font-medium">Status</p>
<div className="space-y-3">

View File

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

View File

@@ -9,6 +9,8 @@ type TransactionExportFilters = {
settledFilter: string | null;
attachmentFilter: string | null;
dividedFilter: string | null;
amountMinFilter: number | null;
amountMaxFilter: number | null;
};
export type TransactionsExportContext = {

View File

@@ -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(