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, parseDateFilterParam,
parsePositiveAmount, parsePositiveAmount,
} from "@/features/transactions/lib/page-helpers"; } from "@/features/transactions/lib/page-helpers";
import { Badge } from "@/shared/components/ui/badge";
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 {
@@ -54,6 +55,11 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/shared/components/ui/drawer"; } from "@/shared/components/ui/drawer";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/shared/components/ui/hover-card";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
import { import {
Popover, Popover,
@@ -73,6 +79,8 @@ import {
ToggleGroup, ToggleGroup,
ToggleGroupItem, ToggleGroupItem,
} from "@/shared/components/ui/toggle-group"; } 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 { slugify } from "@/shared/utils/string";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";
import { import {
@@ -90,6 +98,36 @@ import type {
const FILTER_EMPTY_VALUE = "__all"; 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 normalizeAmountParam = (raw: string): string | null => {
const parsed = parsePositiveAmount(raw.trim()); const parsed = parsePositiveAmount(raw.trim());
return parsed === null ? null : parsed.toString(); 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 ( return (
<div <div
aria-busy={isPending} aria-busy={isPending}
@@ -640,19 +812,27 @@ export function TransactionsFilters({
)} )}
{!hideAdvancedFilters && ( {!hideAdvancedFilters && (
<HoverCard openDelay={200} closeDelay={200}>
<Drawer <Drawer
direction="right" direction="right"
open={drawerOpen} open={drawerOpen}
onOpenChange={setDrawerOpen} onOpenChange={setDrawerOpen}
> >
<HoverCardTrigger asChild>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button <Button
variant="outline" variant="outline"
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={isPending ? "Aplicando filtros" : "Abrir filtros"} aria-label={
isPending ? "Aplicando filtros" : "Abrir filtros"
}
> >
{isPending ? ( {isPending ? (
<Spinner className="size-4" role="presentation" aria-hidden /> <Spinner
className="size-4"
role="presentation"
aria-hidden
/>
) : ( ) : (
<RiFilterLine className="size-4" aria-hidden /> <RiFilterLine className="size-4" aria-hidden />
)} )}
@@ -665,20 +845,41 @@ export function TransactionsFilters({
)} )}
</Button> </Button>
</DrawerTrigger> </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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleReset} onClick={handleReset}
disabled={isPending} disabled={isPending}
aria-label="Limpar filtros" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
> >
<RiCloseLine className="size-3.5" aria-hidden /> Limpar filtros
Limpar
</Button> </Button>
)} </HoverCardContent>
) : null}
<DrawerContent> <DrawerContent>
<DrawerHeader> <DrawerHeader>
<DrawerTitle>Filtros</DrawerTitle> <DrawerTitle>Filtros</DrawerTitle>
@@ -861,7 +1062,9 @@ export function TransactionsFilters({
disabled={isPending} disabled={isPending}
className="text-sm border-dashed" className="text-sm border-dashed"
/> />
<span className="text-xs text-muted-foreground">até</span> <span className="text-xs text-muted-foreground">
até
</span>
<Input <Input
type="number" type="number"
inputMode="decimal" inputMode="decimal"
@@ -951,7 +1154,10 @@ export function TransactionsFilters({
checked={searchParams.get("isDivided") === "true"} checked={searchParams.get("isDivided") === "true"}
disabled={isPending} disabled={isPending}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
handleFilterChange("isDivided", checked ? "true" : null); handleFilterChange(
"isDivided",
checked ? "true" : null,
);
}} }}
/> />
</div> </div>
@@ -1000,6 +1206,7 @@ export function TransactionsFilters({
</DrawerFooter> </DrawerFooter>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
</HoverCard>
)} )}
</div> </div>
</div> </div>