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,
|
||||
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,366 +812,401 @@ export function TransactionsFilters({
|
||||
)}
|
||||
|
||||
{!hideAdvancedFilters && (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
>
|
||||
<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"}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner className="size-4" role="presentation" aria-hidden />
|
||||
) : (
|
||||
<RiFilterLine className="size-4" aria-hidden />
|
||||
)}
|
||||
{isPending ? "Aplicando..." : "Filtros"}
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 size-3 rounded-full bg-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</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" aria-hidden />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filtros</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Selecione os filtros desejados para refinar os lançamentos
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Tipo de lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="type"
|
||||
placeholder="Todos"
|
||||
options={TRANSACTION_TYPES.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<TransactionTypeSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Condição de pagamento
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={conditionOptions}
|
||||
selected={getParamValues("condition")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("condition", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Forma de pagamento
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={paymentOptions}
|
||||
selected={getParamValues("payment")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payment", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Pessoa
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={payerMultiOptions}
|
||||
selected={getParamValues("payer")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payer", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar pessoa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Categoria
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={categoryMultiOptions}
|
||||
selected={getParamValues("category")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("category", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar categoria..."
|
||||
groupOrder={["Despesas", "Receitas", "Outras"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Conta/Cartão
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todos"
|
||||
options={accountCardMultiOptions}
|
||||
selected={getParamValues("accountCard")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("accountCard", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar conta ou cartão..."
|
||||
groupOrder={["Contas", "Cartões"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Intervalo de datas
|
||||
</label>
|
||||
{hasDateRangeFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetDateRange}
|
||||
disabled={isPending}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Limpar período
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
|
||||
<DatePicker
|
||||
value={searchParams.get(DATE_START_PARAM) ?? ""}
|
||||
onChange={(value) =>
|
||||
handleDateFilterChange(DATE_START_PARAM, value)
|
||||
}
|
||||
placeholder="Data inicial"
|
||||
disabled={isPending}
|
||||
inputClassName="border-dashed"
|
||||
compact
|
||||
/>
|
||||
<span className="hidden text-xs text-muted-foreground sm:block">
|
||||
até
|
||||
</span>
|
||||
<DatePicker
|
||||
value={searchParams.get(DATE_END_PARAM) ?? ""}
|
||||
onChange={(value) =>
|
||||
handleDateFilterChange(DATE_END_PARAM, value)
|
||||
}
|
||||
placeholder="Data final"
|
||||
disabled={isPending}
|
||||
inputClassName="border-dashed"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={settledFilterValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
handleFilterChange(
|
||||
"settled",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
);
|
||||
}}
|
||||
<HoverCard openDelay={200} closeDelay={200}>
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="grid w-full grid-cols-3 rounded-md bg-muted/30 p-0.5"
|
||||
aria-label="Status de pagamento"
|
||||
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
|
||||
aria-label={
|
||||
isPending ? "Aplicando filtros" : "Abrir filtros"
|
||||
}
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={FILTER_EMPTY_VALUE}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Todos
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={SETTLED_FILTER_VALUES.PAID}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Pagos
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={SETTLED_FILTER_VALUES.UNPAID}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Não pagos
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-has-attachment"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Com anexo
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-has-attachment"
|
||||
checked={searchParams.get("hasAttachment") === "true"}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange(
|
||||
"hasAttachment",
|
||||
checked ? "true" : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-is-divided"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Somente divididos
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-is-divided"
|
||||
checked={searchParams.get("isDivided") === "true"}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange("isDivided", checked ? "true" : null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{hasActiveFilters
|
||||
? `${activeFilterCount} ${
|
||||
activeFilterCount === 1
|
||||
? "filtro ativo"
|
||||
: "filtros ativos"
|
||||
}`
|
||||
: "Nenhum filtro ativo"}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className="size-4"
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<RiFilterLine className="size-4" aria-hidden />
|
||||
)}
|
||||
{isPending ? "Aplicando..." : "Filtros"}
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner
|
||||
className="size-3"
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
/>
|
||||
Aplicando filtros...
|
||||
</span>
|
||||
) : null}
|
||||
className="absolute -top-1 -right-1 size-3 rounded-full bg-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
</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={handleResetFilters}
|
||||
disabled={isPending || !hasActiveFilters}
|
||||
onClick={handleReset}
|
||||
disabled={isPending}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</HoverCardContent>
|
||||
) : null}
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filtros</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Selecione os filtros desejados para refinar os lançamentos
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||
<div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Tipo de lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="type"
|
||||
placeholder="Todos"
|
||||
options={TRANSACTION_TYPES.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<TransactionTypeSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Condição de pagamento
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={conditionOptions}
|
||||
selected={getParamValues("condition")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("condition", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Forma de pagamento
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={paymentOptions}
|
||||
selected={getParamValues("payment")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payment", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Pessoa
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={payerMultiOptions}
|
||||
selected={getParamValues("payer")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payer", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar pessoa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Categoria
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={categoryMultiOptions}
|
||||
selected={getParamValues("category")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("category", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar categoria..."
|
||||
groupOrder={["Despesas", "Receitas", "Outras"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Conta/Cartão
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todos"
|
||||
options={accountCardMultiOptions}
|
||||
selected={getParamValues("accountCard")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("accountCard", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar conta ou cartão..."
|
||||
groupOrder={["Contas", "Cartões"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Intervalo de datas
|
||||
</label>
|
||||
{hasDateRangeFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetDateRange}
|
||||
disabled={isPending}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Limpar período
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
|
||||
<DatePicker
|
||||
value={searchParams.get(DATE_START_PARAM) ?? ""}
|
||||
onChange={(value) =>
|
||||
handleDateFilterChange(DATE_START_PARAM, value)
|
||||
}
|
||||
placeholder="Data inicial"
|
||||
disabled={isPending}
|
||||
inputClassName="border-dashed"
|
||||
compact
|
||||
/>
|
||||
<span className="hidden text-xs text-muted-foreground sm:block">
|
||||
até
|
||||
</span>
|
||||
<DatePicker
|
||||
value={searchParams.get(DATE_END_PARAM) ?? ""}
|
||||
onChange={(value) =>
|
||||
handleDateFilterChange(DATE_END_PARAM, value)
|
||||
}
|
||||
placeholder="Data final"
|
||||
disabled={isPending}
|
||||
inputClassName="border-dashed"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={settledFilterValue}
|
||||
onValueChange={(value) => {
|
||||
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"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={FILTER_EMPTY_VALUE}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Todos
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={SETTLED_FILTER_VALUES.PAID}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Pagos
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={SETTLED_FILTER_VALUES.UNPAID}
|
||||
className="text-xs font-medium transition-all data-[state=on]:border-foreground data-[state=on]:bg-foreground data-[state=on]:text-background data-[state=on]:shadow-sm"
|
||||
>
|
||||
Não pagos
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-has-attachment"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Com anexo
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-has-attachment"
|
||||
checked={searchParams.get("hasAttachment") === "true"}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange(
|
||||
"hasAttachment",
|
||||
checked ? "true" : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-is-divided"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Somente divididos
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-is-divided"
|
||||
checked={searchParams.get("isDivided") === "true"}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange(
|
||||
"isDivided",
|
||||
checked ? "true" : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
{hasActiveFilters
|
||||
? `${activeFilterCount} ${
|
||||
activeFilterCount === 1
|
||||
? "filtro ativo"
|
||||
: "filtros ativos"
|
||||
}`
|
||||
: "Nenhum filtro ativo"}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Spinner
|
||||
className="size-3"
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
/>
|
||||
Aplicando filtros...
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetFilters}
|
||||
disabled={isPending || !hasActiveFilters}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user