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,
|
settledFilter: null,
|
||||||
attachmentFilter: null,
|
attachmentFilter: null,
|
||||||
dividedFilter: null,
|
dividedFilter: null,
|
||||||
|
amountMinFilter: null,
|
||||||
|
amountMaxFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
settledFilter: z.string().nullable(),
|
settledFilter: z.string().nullable(),
|
||||||
attachmentFilter: z.string().nullable(),
|
attachmentFilter: z.string().nullable(),
|
||||||
dividedFilter: z.string().nullable(),
|
dividedFilter: z.string().nullable(),
|
||||||
|
amountMinFilter: z.number().nullable(),
|
||||||
|
amountMaxFilter: z.number().nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import {
|
|||||||
RiCheckLine,
|
RiCheckLine,
|
||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiExpandUpDownLine,
|
RiExpandUpDownLine,
|
||||||
RiFilter3Line,
|
RiFilterLine,
|
||||||
} from "@remixicon/react";
|
} from "@remixicon/react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import {
|
||||||
|
type ReadonlyURLSearchParams,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
useSearchParams,
|
||||||
|
} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -16,11 +21,14 @@ import {
|
|||||||
useTransition,
|
useTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
|
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +78,36 @@ import type {
|
|||||||
|
|
||||||
const FILTER_EMPTY_VALUE = "__all";
|
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 {
|
interface FilterSelectProps {
|
||||||
param: string;
|
param: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -348,6 +386,7 @@ export function TransactionsFilters({
|
|||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
: pathname;
|
: pathname;
|
||||||
router.replace(target, { scroll: false });
|
router.replace(target, { scroll: false });
|
||||||
|
router.refresh();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
@@ -373,6 +412,17 @@ export function TransactionsFilters({
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [searchValue, currentSearchParam, handleFilterChange]);
|
}, [searchValue, currentSearchParam, handleFilterChange]);
|
||||||
|
|
||||||
|
const [valorMinValue, setValorMinValue] = useDebouncedAmountFilter(
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
|
searchParams,
|
||||||
|
handleFilterChange,
|
||||||
|
);
|
||||||
|
const [valorMaxValue, setValorMaxValue] = useDebouncedAmountFilter(
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
searchParams,
|
||||||
|
handleFilterChange,
|
||||||
|
);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
const periodValue = searchParams.get("periodo");
|
const periodValue = searchParams.get("periodo");
|
||||||
const pageSizeValue = searchParams.get("pageSize");
|
const pageSizeValue = searchParams.get("pageSize");
|
||||||
@@ -384,6 +434,8 @@ export function TransactionsFilters({
|
|||||||
nextParams.set("pageSize", pageSizeValue);
|
nextParams.set("pageSize", pageSizeValue);
|
||||||
}
|
}
|
||||||
setSearchValue("");
|
setSearchValue("");
|
||||||
|
setValorMinValue("");
|
||||||
|
setValorMaxValue("");
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
const target = nextParams.toString()
|
const target = nextParams.toString()
|
||||||
? `${pathname}?${nextParams.toString()}`
|
? `${pathname}?${nextParams.toString()}`
|
||||||
@@ -467,7 +519,9 @@ export function TransactionsFilters({
|
|||||||
searchParams.getAll("accountCard").length > 0 ||
|
searchParams.getAll("accountCard").length > 0 ||
|
||||||
searchParams.get("settled") ||
|
searchParams.get("settled") ||
|
||||||
searchParams.get("hasAttachment") ||
|
searchParams.get("hasAttachment") ||
|
||||||
searchParams.get("isDivided");
|
searchParams.get("isDivided") ||
|
||||||
|
searchParams.get(AMOUNT_MIN_PARAM) ||
|
||||||
|
searchParams.get(AMOUNT_MAX_PARAM);
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
@@ -523,13 +577,27 @@ export function TransactionsFilters({
|
|||||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||||
aria-label="Abrir filtros"
|
aria-label="Abrir filtros"
|
||||||
>
|
>
|
||||||
<RiFilter3Line className="size-4" />
|
<RiFilterLine className="size-4" />
|
||||||
Filtros
|
Filtros
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
<span className="absolute -top-1 -right-1 size-3 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DrawerTrigger>
|
</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>
|
<DrawerContent>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<DrawerTitle>Filtros</DrawerTitle>
|
<DrawerTitle>Filtros</DrawerTitle>
|
||||||
@@ -636,6 +704,37 @@ export function TransactionsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Status</p>
|
<p className="text-sm font-medium">Status</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ export const SETTLED_FILTER_VALUES = {
|
|||||||
PAID: "pago",
|
PAID: "pago",
|
||||||
UNPAID: "nao-pago",
|
UNPAID: "nao-pago",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||||
|
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type TransactionExportFilters = {
|
|||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
|
amountMinFilter: number | null;
|
||||||
|
amountMaxFilter: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { SQL } from "drizzle-orm";
|
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 {
|
import {
|
||||||
cards,
|
cards,
|
||||||
type categories,
|
type categories,
|
||||||
@@ -10,6 +20,8 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import type { SelectOption } from "@/features/transactions/components/types";
|
import type { SelectOption } from "@/features/transactions/components/types";
|
||||||
import {
|
import {
|
||||||
|
AMOUNT_MAX_PARAM,
|
||||||
|
AMOUNT_MIN_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
@@ -46,6 +58,8 @@ export type TransactionSearchFilters = {
|
|||||||
settledFilter: string | null;
|
settledFilter: string | null;
|
||||||
attachmentFilter: string | null;
|
attachmentFilter: string | null;
|
||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
|
amountMinFilter: number | null;
|
||||||
|
amountMaxFilter: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -135,6 +149,13 @@ export const getMultiParam = (
|
|||||||
return list.filter((item): item is string => Boolean(item));
|
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 = (
|
export const extractTransactionSearchFilters = (
|
||||||
params: ResolvedSearchParams,
|
params: ResolvedSearchParams,
|
||||||
): TransactionSearchFilters => ({
|
): TransactionSearchFilters => ({
|
||||||
@@ -148,6 +169,12 @@ export const extractTransactionSearchFilters = (
|
|||||||
settledFilter: getSingleParam(params, "settled"),
|
settledFilter: getSingleParam(params, "settled"),
|
||||||
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
attachmentFilter: getSingleParam(params, "hasAttachment"),
|
||||||
dividedFilter: getSingleParam(params, "isDivided"),
|
dividedFilter: getSingleParam(params, "isDivided"),
|
||||||
|
amountMinFilter: parsePositiveAmount(
|
||||||
|
getSingleParam(params, AMOUNT_MIN_PARAM),
|
||||||
|
),
|
||||||
|
amountMaxFilter: parsePositiveAmount(
|
||||||
|
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -442,6 +469,18 @@ export const buildTransactionWhere = ({
|
|||||||
where.push(eq(transactions.isDivided, true));
|
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);
|
const searchPattern = buildSearchPattern(filters.searchFilter);
|
||||||
if (searchPattern) {
|
if (searchPattern) {
|
||||||
where.push(
|
where.push(
|
||||||
|
|||||||
Reference in New Issue
Block a user