diff --git a/src/features/transactions/components/table/transactions-filters.tsx b/src/features/transactions/components/table/transactions-filters.tsx
index 5756015..923d58f 100644
--- a/src/features/transactions/components/table/transactions-filters.tsx
+++ b/src/features/transactions/components/table/transactions-filters.tsx
@@ -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 (
+
+ {label}
+
+
+ );
+}
+
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 (
-
-
-
- {hasActiveFilters && (
-
- )}
-
-
- Filtros
-
- Selecione os filtros desejados para refinar os lançamentos
-
-
-
-
-
-
-
-
- ({
- value: slugify(v),
- label: v,
- }))}
- widthClass="w-full border-dashed"
- disabled={isPending}
- getParamValue={getParamValue}
- onChange={handleFilterChange}
- renderContent={(label) => (
-
- )}
- />
-
-
-
-
-
- handleMultiFilterChange("condition", values)
- }
- disabled={isPending}
- />
-
-
-
-
-
- handleMultiFilterChange("payment", values)
- }
- disabled={isPending}
- />
-
-
-
-
-
- handleMultiFilterChange("payer", values)
- }
- disabled={isPending}
- searchable
- searchPlaceholder="Buscar pessoa..."
- />
-
-
-
-
-
- handleMultiFilterChange("category", values)
- }
- disabled={isPending}
- searchable
- searchPlaceholder="Buscar categoria..."
- groupOrder={["Despesas", "Receitas", "Outras"]}
- />
-
-
-
-
-
- handleMultiFilterChange("accountCard", values)
- }
- disabled={isPending}
- searchable
- searchPlaceholder="Buscar conta ou cartão..."
- groupOrder={["Contas", "Cartões"]}
- />
-
-
-
-
-
-
-
-
-
-
- {hasDateRangeFilter ? (
-
- ) : null}
-
-
-
- handleDateFilterChange(DATE_START_PARAM, value)
- }
- placeholder="Data inicial"
- disabled={isPending}
- inputClassName="border-dashed"
- compact
- />
-
- até
-
-
- handleDateFilterChange(DATE_END_PARAM, value)
- }
- placeholder="Data final"
- disabled={isPending}
- inputClassName="border-dashed"
- compact
- />
-
-
-
-
-
-
-
-
-
- {
- if (!value) return;
- handleFilterChange(
- "settled",
- value === FILTER_EMPTY_VALUE ? null : value,
- );
- }}
+
+
+
+
+
-
-
-
-
- {
- handleFilterChange(
- "hasAttachment",
- checked ? "true" : null,
- );
- }}
- />
-
-
-
-
- {
- handleFilterChange("isDivided", checked ? "true" : null);
- }}
- />
-
-
-
-
-
-
-
- {hasActiveFilters
- ? `${activeFilterCount} ${
- activeFilterCount === 1
- ? "filtro ativo"
- : "filtros ativos"
- }`
- : "Nenhum filtro ativo"}
-
{isPending ? (
+
+ ) : (
+
+ )}
+ {isPending ? "Aplicando..." : "Filtros"}
+ {hasActiveFilters && (
-
- Aplicando filtros...
-
- ) : null}
+ className="absolute -top-1 -right-1 size-3 rounded-full bg-primary"
+ aria-hidden
+ />
+ )}
+
+
+
+ {activeFilterChips.length > 0 ? (
+
+
+
Filtros ativos
+
+ Remova rapidamente o que não precisa mais.
+
+
+
+ {activeFilterChips.map((chip) => (
+
+ ))}
+
+ ) : null}
+
+
+ Filtros
+
+ Selecione os filtros desejados para refinar os lançamentos
+
+
+
+
+
+
+
+
+ ({
+ value: slugify(v),
+ label: v,
+ }))}
+ widthClass="w-full border-dashed"
+ disabled={isPending}
+ getParamValue={getParamValue}
+ onChange={handleFilterChange}
+ renderContent={(label) => (
+
+ )}
+ />
+
+
+
+
+
+ handleMultiFilterChange("condition", values)
+ }
+ disabled={isPending}
+ />
+
+
+
+
+
+ handleMultiFilterChange("payment", values)
+ }
+ disabled={isPending}
+ />
+
+
+
+
+
+ handleMultiFilterChange("payer", values)
+ }
+ disabled={isPending}
+ searchable
+ searchPlaceholder="Buscar pessoa..."
+ />
+
+
+
+
+
+ handleMultiFilterChange("category", values)
+ }
+ disabled={isPending}
+ searchable
+ searchPlaceholder="Buscar categoria..."
+ groupOrder={["Despesas", "Receitas", "Outras"]}
+ />
+
+
+
+
+
+ handleMultiFilterChange("accountCard", values)
+ }
+ disabled={isPending}
+ searchable
+ searchPlaceholder="Buscar conta ou cartão..."
+ groupOrder={["Contas", "Cartões"]}
+ />
+
+
+
+
+
+
+
+
+
+
+ {hasDateRangeFilter ? (
+
+ ) : null}
+
+
+
+ handleDateFilterChange(DATE_START_PARAM, value)
+ }
+ placeholder="Data inicial"
+ disabled={isPending}
+ inputClassName="border-dashed"
+ compact
+ />
+
+ até
+
+
+ handleDateFilterChange(DATE_END_PARAM, value)
+ }
+ placeholder="Data final"
+ disabled={isPending}
+ inputClassName="border-dashed"
+ compact
+ />
+
+
+
+
+
+
+
+
+
+ {
+ if (!value) return;
+ handleFilterChange(
+ "settled",
+ value === FILTER_EMPTY_VALUE ? null : value,
+ );
+ }}
+ variant="outline"
+ size="sm"
+ className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
+ aria-label="Status de pagamento"
+ >
+
+ Todos
+
+
+ Pagos
+
+
+ Não pagos
+
+
+
+
+
+
+ {
+ handleFilterChange(
+ "hasAttachment",
+ checked ? "true" : null,
+ );
+ }}
+ />
+
+
+
+
+ {
+ handleFilterChange(
+ "isDivided",
+ checked ? "true" : null,
+ );
+ }}
+ />
+
-
-
-
+
+
+
+
+
+ {hasActiveFilters
+ ? `${activeFilterCount} ${
+ activeFilterCount === 1
+ ? "filtro ativo"
+ : "filtros ativos"
+ }`
+ : "Nenhum filtro ativo"}
+
+ {isPending ? (
+
+
+ Aplicando filtros...
+
+ ) : null}
+
+
+
+
+
+
+
)}