mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(lancamentos): adiciona filtro por intervalo de datas
This commit is contained in:
@@ -87,6 +87,8 @@ const EMPTY_FILTERS: TransactionSearchFilters = {
|
|||||||
dividedFilter: null,
|
dividedFilter: null,
|
||||||
amountMinFilter: null,
|
amountMinFilter: null,
|
||||||
amountMaxFilter: null,
|
amountMaxFilter: null,
|
||||||
|
dateStartFilter: null,
|
||||||
|
dateEndFilter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEmptySlugMaps = (): SlugMaps => ({
|
const createEmptySlugMaps = (): SlugMaps => ({
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
|||||||
dividedFilter: z.string().nullable(),
|
dividedFilter: z.string().nullable(),
|
||||||
amountMinFilter: z.number().nullable(),
|
amountMinFilter: z.number().nullable(),
|
||||||
amountMaxFilter: z.number().nullable(),
|
amountMaxFilter: z.number().nullable(),
|
||||||
|
dateStartFilter: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.nullable(),
|
||||||
|
dateEndFilter: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
accountId: z.string().min(1).nullable().optional(),
|
accountId: z.string().min(1).nullable().optional(),
|
||||||
cardId: z.string().min(1).nullable().optional(),
|
cardId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
AMOUNT_MAX_PARAM,
|
AMOUNT_MAX_PARAM,
|
||||||
AMOUNT_MIN_PARAM,
|
AMOUNT_MIN_PARAM,
|
||||||
|
DATE_END_PARAM,
|
||||||
|
DATE_START_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
TRANSACTION_TYPES,
|
TRANSACTION_TYPES,
|
||||||
} from "@/features/transactions/lib/constants";
|
} from "@/features/transactions/lib/constants";
|
||||||
import { parsePositiveAmount } from "@/features/transactions/lib/page-helpers";
|
import {
|
||||||
|
parseDateFilterParam,
|
||||||
|
parsePositiveAmount,
|
||||||
|
} from "@/features/transactions/lib/page-helpers";
|
||||||
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 {
|
||||||
@@ -39,6 +44,7 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/shared/components/ui/command";
|
} from "@/shared/components/ui/command";
|
||||||
|
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerContent,
|
DrawerContent,
|
||||||
@@ -60,7 +66,12 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/shared/components/ui/select";
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from "@/shared/components/ui/toggle-group";
|
||||||
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 {
|
||||||
@@ -83,6 +94,9 @@ const normalizeAmountParam = (raw: string): string | null => {
|
|||||||
return parsed === null ? null : parsed.toString();
|
return parsed === null ? null : parsed.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeDateParam = (raw: string): string | null =>
|
||||||
|
parseDateFilterParam(raw.trim());
|
||||||
|
|
||||||
function useDebouncedAmountFilter(
|
function useDebouncedAmountFilter(
|
||||||
param: string,
|
param: string,
|
||||||
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||||
@@ -135,6 +149,7 @@ function FilterSelect({
|
|||||||
value === FILTER_EMPTY_VALUE
|
value === FILTER_EMPTY_VALUE
|
||||||
? placeholder
|
? placeholder
|
||||||
: (current?.label ?? placeholder);
|
: (current?.label ?? placeholder);
|
||||||
|
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -148,8 +163,13 @@ function FilterSelect({
|
|||||||
className={cn("text-sm border-dashed", widthClass)}
|
className={cn("text-sm border-dashed", widthClass)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span
|
||||||
{value !== FILTER_EMPTY_VALUE && current && renderContent
|
className={cn(
|
||||||
|
"truncate",
|
||||||
|
hasSelection ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasSelection && renderContent
|
||||||
? renderContent(current.label)
|
? renderContent(current.label)
|
||||||
: displayLabel}
|
: displayLabel}
|
||||||
</span>
|
</span>
|
||||||
@@ -255,12 +275,19 @@ function MultiSelectFilter({
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn(
|
||||||
"justify-between text-sm border-dashed font-normal",
|
"justify-between text-sm border-dashed font-normal shadow-none",
|
||||||
widthClass,
|
widthClass,
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className="truncate flex items-center gap-2">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"truncate flex items-center gap-2",
|
||||||
|
selectedOptions.length > 0
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</span>
|
</span>
|
||||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
@@ -392,6 +419,13 @@ export function TransactionsFilters({
|
|||||||
[searchParams, pathname, router],
|
[searchParams, pathname, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDateFilterChange = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
handleFilterChange(key, normalizeDateParam(value));
|
||||||
|
},
|
||||||
|
[handleFilterChange],
|
||||||
|
);
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||||
const currentSearchParam = searchParams.get("q") ?? "";
|
const currentSearchParam = searchParams.get("q") ?? "";
|
||||||
|
|
||||||
@@ -509,25 +543,46 @@ export function TransactionsFilters({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const hasDateRangeFilter =
|
||||||
const hasActiveFilters =
|
Boolean(searchParams.get(DATE_START_PARAM)) ||
|
||||||
searchParams.get("type") ||
|
Boolean(searchParams.get(DATE_END_PARAM));
|
||||||
searchParams.getAll("condition").length > 0 ||
|
const hasAmountFilter =
|
||||||
searchParams.getAll("payment").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
|
||||||
searchParams.getAll("payer").length > 0 ||
|
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
|
||||||
searchParams.getAll("category").length > 0 ||
|
const activeFilterCount = [
|
||||||
searchParams.getAll("accountCard").length > 0 ||
|
Boolean(searchParams.get("type")),
|
||||||
searchParams.get("settled") ||
|
searchParams.getAll("condition").length > 0,
|
||||||
searchParams.get("hasAttachment") ||
|
searchParams.getAll("payment").length > 0,
|
||||||
searchParams.get("isDivided") ||
|
searchParams.getAll("payer").length > 0,
|
||||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
searchParams.getAll("category").length > 0,
|
||||||
searchParams.get(AMOUNT_MAX_PARAM);
|
searchParams.getAll("accountCard").length > 0,
|
||||||
|
Boolean(searchParams.get("settled")),
|
||||||
|
Boolean(searchParams.get("hasAttachment")),
|
||||||
|
Boolean(searchParams.get("isDivided")),
|
||||||
|
hasAmountFilter,
|
||||||
|
hasDateRangeFilter,
|
||||||
|
].filter(Boolean).length;
|
||||||
|
const hasActiveFilters = activeFilterCount > 0;
|
||||||
|
const settledFilterValue = searchParams.get("settled") ?? FILTER_EMPTY_VALUE;
|
||||||
|
|
||||||
const handleResetFilters = () => {
|
const handleResetFilters = () => {
|
||||||
handleReset();
|
handleReset();
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetDateRange = () => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
|
nextParams.delete(DATE_START_PARAM);
|
||||||
|
nextParams.delete(DATE_END_PARAM);
|
||||||
|
nextParams.delete("page");
|
||||||
|
startTransition(() => {
|
||||||
|
const target = nextParams.toString()
|
||||||
|
? `${pathname}?${nextParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(target, { scroll: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -607,182 +662,234 @@ export function TransactionsFilters({
|
|||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="text-sm font-medium">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
Tipo de Lançamento
|
<div className="space-y-1.5">
|
||||||
</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<FilterSelect
|
Tipo de lançamento
|
||||||
param="type"
|
</label>
|
||||||
placeholder="Todos"
|
<FilterSelect
|
||||||
options={TRANSACTION_TYPES.map((v) => ({
|
param="type"
|
||||||
value: slugify(v),
|
placeholder="Todos"
|
||||||
label: v,
|
options={TRANSACTION_TYPES.map((v) => ({
|
||||||
}))}
|
value: slugify(v),
|
||||||
widthClass="w-full border-dashed"
|
label: v,
|
||||||
disabled={isPending}
|
}))}
|
||||||
getParamValue={getParamValue}
|
widthClass="w-full border-dashed"
|
||||||
onChange={handleFilterChange}
|
disabled={isPending}
|
||||||
renderContent={(label) => (
|
getParamValue={getParamValue}
|
||||||
<TransactionTypeSelectContent label={label} />
|
onChange={handleFilterChange}
|
||||||
)}
|
renderContent={(label) => (
|
||||||
/>
|
<TransactionTypeSelectContent label={label} />
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Condição de Lançamento
|
Condição de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={conditionOptions}
|
options={conditionOptions}
|
||||||
selected={getParamValues("condition")}
|
selected={getParamValues("condition")}
|
||||||
onChange={(values) =>
|
onChange={(values) =>
|
||||||
handleMultiFilterChange("condition", values)
|
handleMultiFilterChange("condition", values)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
Forma de Pagamento
|
Forma de pagamento
|
||||||
</label>
|
</label>
|
||||||
<MultiSelectFilter
|
<MultiSelectFilter
|
||||||
placeholder="Todas"
|
placeholder="Todas"
|
||||||
options={paymentOptions}
|
options={paymentOptions}
|
||||||
selected={getParamValues("payment")}
|
selected={getParamValues("payment")}
|
||||||
onChange={(values) =>
|
onChange={(values) =>
|
||||||
handleMultiFilterChange("payment", values)
|
handleMultiFilterChange("payment", values)
|
||||||
}
|
}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Pessoa</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<MultiSelectFilter
|
Pessoa
|
||||||
placeholder="Todas"
|
</label>
|
||||||
options={payerMultiOptions}
|
<MultiSelectFilter
|
||||||
selected={getParamValues("payer")}
|
placeholder="Todas"
|
||||||
onChange={(values) =>
|
options={payerMultiOptions}
|
||||||
handleMultiFilterChange("payer", values)
|
selected={getParamValues("payer")}
|
||||||
}
|
onChange={(values) =>
|
||||||
disabled={isPending}
|
handleMultiFilterChange("payer", values)
|
||||||
searchable
|
}
|
||||||
searchPlaceholder="Buscar pessoa..."
|
disabled={isPending}
|
||||||
/>
|
searchable
|
||||||
</div>
|
searchPlaceholder="Buscar pessoa..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Categoria</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<MultiSelectFilter
|
Categoria
|
||||||
placeholder="Todas"
|
</label>
|
||||||
options={categoryMultiOptions}
|
<MultiSelectFilter
|
||||||
selected={getParamValues("category")}
|
placeholder="Todas"
|
||||||
onChange={(values) =>
|
options={categoryMultiOptions}
|
||||||
handleMultiFilterChange("category", values)
|
selected={getParamValues("category")}
|
||||||
}
|
onChange={(values) =>
|
||||||
disabled={isPending}
|
handleMultiFilterChange("category", values)
|
||||||
searchable
|
}
|
||||||
searchPlaceholder="Buscar categoria..."
|
disabled={isPending}
|
||||||
/>
|
searchable
|
||||||
</div>
|
searchPlaceholder="Buscar categoria..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium">Conta/Cartão</label>
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<MultiSelectFilter
|
Conta/Cartão
|
||||||
placeholder="Todos"
|
</label>
|
||||||
options={accountCardMultiOptions}
|
<MultiSelectFilter
|
||||||
selected={getParamValues("accountCard")}
|
placeholder="Todos"
|
||||||
onChange={(values) =>
|
options={accountCardMultiOptions}
|
||||||
handleMultiFilterChange("accountCard", values)
|
selected={getParamValues("accountCard")}
|
||||||
}
|
onChange={(values) =>
|
||||||
disabled={isPending}
|
handleMultiFilterChange("accountCard", values)
|
||||||
searchable
|
}
|
||||||
searchPlaceholder="Buscar conta ou cartão..."
|
disabled={isPending}
|
||||||
groupOrder={["Contas", "Cartões"]}
|
searchable
|
||||||
/>
|
searchPlaceholder="Buscar conta ou cartão..."
|
||||||
</div>
|
groupOrder={["Contas", "Cartões"]}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<label className="text-sm font-medium">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>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm font-medium">Status</p>
|
<div className="space-y-2">
|
||||||
<div className="space-y-3">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<label className="text-xs font-medium text-muted-foreground">
|
||||||
<label
|
Período
|
||||||
htmlFor="filter-pago"
|
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
|
||||||
>
|
|
||||||
Somente pagos
|
|
||||||
</label>
|
</label>
|
||||||
<Switch
|
{hasDateRangeFilter ? (
|
||||||
id="filter-pago"
|
<button
|
||||||
checked={
|
type="button"
|
||||||
searchParams.get("settled") ===
|
onClick={handleResetDateRange}
|
||||||
SETTLED_FILTER_VALUES.PAID
|
disabled={isPending}
|
||||||
}
|
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:pointer-events-none disabled:opacity-50"
|
||||||
disabled={isPending}
|
>
|
||||||
onCheckedChange={(checked) => {
|
Limpar período
|
||||||
handleFilterChange(
|
</button>
|
||||||
"settled",
|
) : null}
|
||||||
checked ? SETTLED_FILTER_VALUES.PAID : null,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
|
||||||
<label
|
<DatePicker
|
||||||
htmlFor="filter-nao-pago"
|
value={searchParams.get(DATE_START_PARAM) ?? ""}
|
||||||
className="text-sm text-muted-foreground cursor-pointer"
|
onChange={(value) =>
|
||||||
>
|
handleDateFilterChange(DATE_START_PARAM, value)
|
||||||
Somente não pagos
|
|
||||||
</label>
|
|
||||||
<Switch
|
|
||||||
id="filter-nao-pago"
|
|
||||||
checked={
|
|
||||||
searchParams.get("settled") ===
|
|
||||||
SETTLED_FILTER_VALUES.UNPAID
|
|
||||||
}
|
}
|
||||||
|
placeholder="Data inicial"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onCheckedChange={(checked) => {
|
inputClassName="border-dashed"
|
||||||
handleFilterChange(
|
compact
|
||||||
"settled",
|
/>
|
||||||
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -824,14 +931,27 @@ export function TransactionsFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DrawerFooter>
|
<DrawerFooter>
|
||||||
<Button
|
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||||
type="button"
|
<span className="text-xs text-muted-foreground">
|
||||||
variant="outline"
|
{hasActiveFilters
|
||||||
onClick={handleResetFilters}
|
? `${activeFilterCount} ${
|
||||||
disabled={isPending || !hasActiveFilters}
|
activeFilterCount === 1
|
||||||
>
|
? "filtro ativo"
|
||||||
Limpar filtros
|
: "filtros ativos"
|
||||||
</Button>
|
}`
|
||||||
|
: "Nenhum filtro ativo"}
|
||||||
|
</span>
|
||||||
|
<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>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
|
|||||||
return { jsPDF, autoTable };
|
return { jsPDF, autoTable };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatPeriodDate = (dateString: string) =>
|
||||||
|
formatDateOnly(dateString, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}) ?? dateString;
|
||||||
|
|
||||||
export function TransactionsExport({
|
export function TransactionsExport({
|
||||||
lancamentos,
|
lancamentos,
|
||||||
period,
|
period,
|
||||||
exportContext,
|
exportContext,
|
||||||
}: TransactionsExportProps) {
|
}: TransactionsExportProps) {
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const dateStartFilter = exportContext?.filters.dateStartFilter ?? null;
|
||||||
|
const dateEndFilter = exportContext?.filters.dateEndFilter ?? null;
|
||||||
|
const periodLabel =
|
||||||
|
dateStartFilter || dateEndFilter
|
||||||
|
? `${dateStartFilter ? formatPeriodDate(dateStartFilter) : "Início"} até ${
|
||||||
|
dateEndFilter ? formatPeriodDate(dateEndFilter) : "hoje"
|
||||||
|
}`
|
||||||
|
: displayPeriod(period);
|
||||||
|
const filePeriodSlug =
|
||||||
|
dateStartFilter || dateEndFilter
|
||||||
|
? `${dateStartFilter ?? "inicio"}-${dateEndFilter ?? "hoje"}`
|
||||||
|
: period;
|
||||||
|
|
||||||
const getFileName = (extension: string) => {
|
const getFileName = (extension: string) => {
|
||||||
return `lancamentos-${period}.${extension}`;
|
return `lancamentos-${filePeriodSlug}.${extension}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -251,7 +270,7 @@ export function TransactionsExport({
|
|||||||
doc.text("Lançamentos", titleX, 15);
|
doc.text("Lançamentos", titleX, 15);
|
||||||
|
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
doc.text(`Período: ${periodLabel}`, titleX, 22);
|
||||||
doc.text(
|
doc.text(
|
||||||
`Gerado em: ${
|
`Gerado em: ${
|
||||||
formatDateTime(new Date(), {
|
formatDateTime(new Date(), {
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
|
|||||||
|
|
||||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||||
export const AMOUNT_MAX_PARAM = "valorMax";
|
export const AMOUNT_MAX_PARAM = "valorMax";
|
||||||
|
export const DATE_START_PARAM = "dataInicio";
|
||||||
|
export const DATE_END_PARAM = "dataFim";
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type TransactionExportFilters = {
|
|||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
amountMinFilter: number | null;
|
amountMinFilter: number | null;
|
||||||
amountMaxFilter: number | null;
|
amountMaxFilter: number | null;
|
||||||
|
dateStartFilter: string | null;
|
||||||
|
dateEndFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TransactionsExportContext = {
|
export type TransactionsExportContext = {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type { SelectOption } from "@/features/transactions/components/types";
|
|||||||
import {
|
import {
|
||||||
AMOUNT_MAX_PARAM,
|
AMOUNT_MAX_PARAM,
|
||||||
AMOUNT_MIN_PARAM,
|
AMOUNT_MIN_PARAM,
|
||||||
|
DATE_END_PARAM,
|
||||||
|
DATE_START_PARAM,
|
||||||
PAYMENT_METHODS,
|
PAYMENT_METHODS,
|
||||||
SETTLED_FILTER_VALUES,
|
SETTLED_FILTER_VALUES,
|
||||||
TRANSACTION_CONDITIONS,
|
TRANSACTION_CONDITIONS,
|
||||||
@@ -38,7 +40,7 @@ import {
|
|||||||
PAYER_ROLE_ADMIN,
|
PAYER_ROLE_ADMIN,
|
||||||
PAYER_ROLE_THIRD_PARTY,
|
PAYER_ROLE_THIRD_PARTY,
|
||||||
} from "@/shared/lib/payers/constants";
|
} from "@/shared/lib/payers/constants";
|
||||||
import { toDateOnlyString } from "@/shared/utils/date";
|
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
|
||||||
import { slugify } from "@/shared/utils/string";
|
import { slugify } from "@/shared/utils/string";
|
||||||
|
|
||||||
type PayerRow = typeof payers.$inferSelect;
|
type PayerRow = typeof payers.$inferSelect;
|
||||||
@@ -66,6 +68,8 @@ export type TransactionSearchFilters = {
|
|||||||
dividedFilter: string | null;
|
dividedFilter: string | null;
|
||||||
amountMinFilter: number | null;
|
amountMinFilter: number | null;
|
||||||
amountMaxFilter: number | null;
|
amountMaxFilter: number | null;
|
||||||
|
dateStartFilter: string | null;
|
||||||
|
dateEndFilter: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BaseSluggedOption = {
|
type BaseSluggedOption = {
|
||||||
@@ -162,6 +166,14 @@ export const parsePositiveAmount = (value: string | null): number | null => {
|
|||||||
return Math.round(normalized * 100) / 100;
|
return Math.round(normalized * 100) / 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const parseDateFilterParam = (value: string | null): string | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) return null;
|
||||||
|
const parsed = parseLocalDateString(normalized);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : normalized;
|
||||||
|
};
|
||||||
|
|
||||||
export const extractTransactionSearchFilters = (
|
export const extractTransactionSearchFilters = (
|
||||||
params: ResolvedSearchParams,
|
params: ResolvedSearchParams,
|
||||||
): TransactionSearchFilters => ({
|
): TransactionSearchFilters => ({
|
||||||
@@ -181,6 +193,10 @@ export const extractTransactionSearchFilters = (
|
|||||||
amountMaxFilter: parsePositiveAmount(
|
amountMaxFilter: parsePositiveAmount(
|
||||||
getSingleParam(params, AMOUNT_MAX_PARAM),
|
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||||
),
|
),
|
||||||
|
dateStartFilter: parseDateFilterParam(
|
||||||
|
getSingleParam(params, DATE_START_PARAM),
|
||||||
|
),
|
||||||
|
dateEndFilter: parseDateFilterParam(getSingleParam(params, DATE_END_PARAM)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resolveTransactionPagination = (
|
export const resolveTransactionPagination = (
|
||||||
@@ -377,10 +393,29 @@ export const buildTransactionWhere = ({
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
payerId?: string;
|
payerId?: string;
|
||||||
}): SQL[] => {
|
}): SQL[] => {
|
||||||
const where: SQL[] = [
|
const where: SQL[] = [eq(transactions.userId, userId)];
|
||||||
eq(transactions.userId, userId),
|
|
||||||
eq(transactions.period, period),
|
if (filters.dateStartFilter || filters.dateEndFilter) {
|
||||||
];
|
if (filters.dateStartFilter) {
|
||||||
|
where.push(
|
||||||
|
gte(
|
||||||
|
transactions.purchaseDate,
|
||||||
|
parseLocalDateString(filters.dateStartFilter),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateEndFilter) {
|
||||||
|
where.push(
|
||||||
|
lte(
|
||||||
|
transactions.purchaseDate,
|
||||||
|
parseLocalDateString(filters.dateEndFilter),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
where.push(eq(transactions.period, period));
|
||||||
|
}
|
||||||
|
|
||||||
if (payerId) {
|
if (payerId) {
|
||||||
where.push(eq(transactions.payerId, payerId));
|
where.push(eq(transactions.payerId, payerId));
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface DatePickerProps {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
|
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -87,6 +88,7 @@ export function DatePicker({
|
|||||||
required = false,
|
required = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
inputClassName,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@@ -140,7 +142,7 @@ export function DatePicker({
|
|||||||
id={id}
|
id={id}
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="bg-background pr-10"
|
className={cn("bg-background pr-10", inputClassName)}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleInputKeyDown}
|
onKeyDown={handleInputKeyDown}
|
||||||
required={required}
|
required={required}
|
||||||
|
|||||||
Reference in New Issue
Block a user