mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-11 03:31:47 +00:00
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:
@@ -85,6 +85,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
||||
settledFilter: null,
|
||||
attachmentFilter: null,
|
||||
dividedFilter: null,
|
||||
amountMinFilter: null,
|
||||
amountMaxFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -9,6 +9,8 @@ type TransactionExportFilters = {
|
||||
settledFilter: string | null;
|
||||
attachmentFilter: string | null;
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
};
|
||||
|
||||
export type TransactionsExportContext = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user