mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(lancamentos): melhora painel de filtros ativos
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user