feat(lancamentos): melhora painel de filtros ativos

This commit is contained in:
Felipe Coutinho
2026-05-31 15:18:23 -03:00
parent 02ee5bb758
commit 402f0072af

View File

@@ -34,6 +34,7 @@ import {
parseDateFilterParam,
parsePositiveAmount,
} from "@/features/transactions/lib/page-helpers";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
@@ -54,6 +55,11 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/shared/components/ui/drawer";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
import { Input } from "@/shared/components/ui/input";
import {
Popover,
@@ -73,6 +79,8 @@ import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import { formatCurrency } from "@/shared/utils/currency";
import { formatDateOnly } from "@/shared/utils/date";
import { slugify } from "@/shared/utils/string";
import { cn } from "@/shared/utils/ui";
import {
@@ -90,6 +98,36 @@ import type {
const FILTER_EMPTY_VALUE = "__all";
type ActiveFilterChipProps = {
label: string;
onRemove: () => void;
disabled?: boolean;
};
function ActiveFilterChip({
label,
onRemove,
disabled,
}: ActiveFilterChipProps) {
return (
<Badge
variant="secondary"
className="gap-1 border border-border/70 bg-secondary/70 py-1 pr-1 pl-2.5 font-normal text-secondary-foreground"
>
<span>{label}</span>
<button
type="button"
onClick={onRemove}
disabled={disabled}
className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
aria-label={`Remover filtro ${label}`}
>
<RiCloseLine className="size-3" aria-hidden />
</button>
</Badge>
);
}
const normalizeAmountParam = (raw: string): string | null => {
const parsed = parsePositiveAmount(raw.trim());
return parsed === null ? null : parsed.toString();
@@ -601,6 +639,140 @@ export function TransactionsFilters({
});
};
const handleRemoveParams = (keys: string[]) => {
const nextParams = new URLSearchParams(searchParams.toString());
for (const key of keys) {
nextParams.delete(key);
}
nextParams.delete("page");
if (keys.includes(AMOUNT_MIN_PARAM)) {
setValorMinValue("");
}
if (keys.includes(AMOUNT_MAX_PARAM)) {
setValorMaxValue("");
}
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
};
const handleRemoveMultiValue = (key: string, value: string) => {
handleMultiFilterChange(
key,
getParamValues(key).filter((currentValue) => currentValue !== value),
);
};
const activeFilterChips: Array<{
key: string;
label: string;
onRemove: () => void;
}> = [];
const typeValue = searchParams.get("type");
if (typeValue) {
const label =
TRANSACTION_TYPES.find((value) => slugify(value) === typeValue) ??
typeValue;
activeFilterChips.push({
key: `type-${typeValue}`,
label: `Tipo: ${label}`,
onRemove: () => handleRemoveParams(["type"]),
});
}
const addMultiValueChips = (
param: string,
prefix: string,
options: MultiOption[],
) => {
const labels = new Map(
options.map((option) => [option.value, option.label]),
);
for (const value of getParamValues(param)) {
activeFilterChips.push({
key: `${param}-${value}`,
label: `${prefix}: ${labels.get(value) ?? value}`,
onRemove: () => handleRemoveMultiValue(param, value),
});
}
};
addMultiValueChips("condition", "Condição", conditionOptions);
addMultiValueChips("payment", "Pagamento", paymentOptions);
addMultiValueChips("payer", "Pessoa", payerMultiOptions);
addMultiValueChips("category", "Categoria", categoryMultiOptions);
addMultiValueChips("accountCard", "Conta/cartão", accountCardMultiOptions);
const settledValue = searchParams.get("settled");
if (settledValue) {
activeFilterChips.push({
key: `settled-${settledValue}`,
label:
settledValue === SETTLED_FILTER_VALUES.PAID
? "Status: Pago"
: "Status: Não pago",
onRemove: () => handleRemoveParams(["settled"]),
});
}
if (searchParams.get("hasAttachment") === "true") {
activeFilterChips.push({
key: "has-attachment",
label: "Com anexo",
onRemove: () => handleRemoveParams(["hasAttachment"]),
});
}
if (searchParams.get("isDivided") === "true") {
activeFilterChips.push({
key: "is-divided",
label: "Somente divididos",
onRemove: () => handleRemoveParams(["isDivided"]),
});
}
if (hasAmountFilter) {
const minValue = parsePositiveAmount(
searchParams.get(AMOUNT_MIN_PARAM) ?? "",
);
const maxValue = parsePositiveAmount(
searchParams.get(AMOUNT_MAX_PARAM) ?? "",
);
const label =
minValue !== null && maxValue !== null
? `Valor: ${formatCurrency(minValue)} até ${formatCurrency(maxValue)}`
: minValue !== null
? `Valor: a partir de ${formatCurrency(minValue)}`
: `Valor: até ${formatCurrency(maxValue ?? 0)}`;
activeFilterChips.push({
key: "amount-range",
label,
onRemove: () => handleRemoveParams([AMOUNT_MIN_PARAM, AMOUNT_MAX_PARAM]),
});
}
if (hasDateRangeFilter) {
const startValue = formatDateOnly(searchParams.get(DATE_START_PARAM));
const endValue = formatDateOnly(searchParams.get(DATE_END_PARAM));
const label =
startValue && endValue
? `Datas: ${startValue} até ${endValue}`
: startValue
? `Datas: a partir de ${startValue}`
: `Datas: até ${endValue}`;
activeFilterChips.push({
key: "date-range",
label,
onRemove: () => handleRemoveParams([DATE_START_PARAM, DATE_END_PARAM]),
});
}
return (
<div
aria-busy={isPending}
@@ -640,19 +812,27 @@ export function TransactionsFilters({
)}
{!hideAdvancedFilters && (
<HoverCard openDelay={200} closeDelay={200}>
<Drawer
direction="right"
open={drawerOpen}
onOpenChange={setDrawerOpen}
>
<HoverCardTrigger asChild>
<DrawerTrigger asChild>
<Button
variant="outline"
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
aria-label={isPending ? "Aplicando filtros" : "Abrir filtros"}
aria-label={
isPending ? "Aplicando filtros" : "Abrir filtros"
}
>
{isPending ? (
<Spinner className="size-4" role="presentation" aria-hidden />
<Spinner
className="size-4"
role="presentation"
aria-hidden
/>
) : (
<RiFilterLine className="size-4" aria-hidden />
)}
@@ -665,20 +845,41 @@ export function TransactionsFilters({
)}
</Button>
</DrawerTrigger>
{hasActiveFilters && (
</HoverCardTrigger>
{activeFilterChips.length > 0 ? (
<HoverCardContent
align="end"
className="w-80 space-y-3"
aria-label="Filtros ativos"
>
<div className="space-y-0.5">
<p className="text-sm font-medium">Filtros ativos</p>
<p className="text-xs text-muted-foreground">
Remova rapidamente o que não precisa mais.
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{activeFilterChips.map((chip) => (
<ActiveFilterChip
key={chip.key}
label={chip.label}
onRemove={chip.onRemove}
disabled={isPending}
/>
))}
</div>
<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"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="size-3.5" aria-hidden />
Limpar
Limpar filtros
</Button>
)}
</HoverCardContent>
) : null}
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Filtros</DrawerTitle>
@@ -861,7 +1062,9 @@ export function TransactionsFilters({
disabled={isPending}
className="text-sm border-dashed"
/>
<span className="text-xs text-muted-foreground">até</span>
<span className="text-xs text-muted-foreground">
até
</span>
<Input
type="number"
inputMode="decimal"
@@ -951,7 +1154,10 @@ export function TransactionsFilters({
checked={searchParams.get("isDivided") === "true"}
disabled={isPending}
onCheckedChange={(checked) => {
handleFilterChange("isDivided", checked ? "true" : null);
handleFilterChange(
"isDivided",
checked ? "true" : null,
);
}}
/>
</div>
@@ -1000,6 +1206,7 @@ export function TransactionsFilters({
</DrawerFooter>
</DrawerContent>
</Drawer>
</HoverCard>
)}
</div>
</div>