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,
|
||||
amountMinFilter: null,
|
||||
amountMaxFilter: null,
|
||||
dateStartFilter: null,
|
||||
dateEndFilter: null,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
|
||||
@@ -36,6 +36,14 @@ const exportTransactionsSchema: z.ZodType<TransactionsExportContext> = z.object(
|
||||
dividedFilter: z.string().nullable(),
|
||||
amountMinFilter: 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(),
|
||||
cardId: z.string().min(1).nullable().optional(),
|
||||
|
||||
@@ -23,12 +23,17 @@ import {
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_PARAM,
|
||||
DATE_END_PARAM,
|
||||
DATE_START_PARAM,
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
TRANSACTION_TYPES,
|
||||
} 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 { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
@@ -39,6 +44,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/components/ui/command";
|
||||
import { DatePicker } from "@/shared/components/ui/date-picker";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
@@ -60,7 +66,12 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Separator } from "@/shared/components/ui/separator";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/shared/components/ui/toggle-group";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
import { cn } from "@/shared/utils/ui";
|
||||
import {
|
||||
@@ -83,6 +94,9 @@ const normalizeAmountParam = (raw: string): string | null => {
|
||||
return parsed === null ? null : parsed.toString();
|
||||
};
|
||||
|
||||
const normalizeDateParam = (raw: string): string | null =>
|
||||
parseDateFilterParam(raw.trim());
|
||||
|
||||
function useDebouncedAmountFilter(
|
||||
param: string,
|
||||
searchParams: URLSearchParams | ReadonlyURLSearchParams,
|
||||
@@ -135,6 +149,7 @@ function FilterSelect({
|
||||
value === FILTER_EMPTY_VALUE
|
||||
? placeholder
|
||||
: (current?.label ?? placeholder);
|
||||
const hasSelection = value !== FILTER_EMPTY_VALUE && Boolean(current);
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -148,8 +163,13 @@ function FilterSelect({
|
||||
className={cn("text-sm border-dashed", widthClass)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value !== FILTER_EMPTY_VALUE && current && renderContent
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
hasSelection ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasSelection && renderContent
|
||||
? renderContent(current.label)
|
||||
: displayLabel}
|
||||
</span>
|
||||
@@ -255,12 +275,19 @@ function MultiSelectFilter({
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"justify-between text-sm border-dashed font-normal",
|
||||
"justify-between text-sm border-dashed font-normal shadow-none",
|
||||
widthClass,
|
||||
)}
|
||||
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}
|
||||
</span>
|
||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
@@ -392,6 +419,13 @@ export function TransactionsFilters({
|
||||
[searchParams, pathname, router],
|
||||
);
|
||||
|
||||
const handleDateFilterChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
handleFilterChange(key, normalizeDateParam(value));
|
||||
},
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||
const currentSearchParam = searchParams.get("q") ?? "";
|
||||
|
||||
@@ -509,25 +543,46 @@ export function TransactionsFilters({
|
||||
);
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchParams.get("type") ||
|
||||
searchParams.getAll("condition").length > 0 ||
|
||||
searchParams.getAll("payment").length > 0 ||
|
||||
searchParams.getAll("payer").length > 0 ||
|
||||
searchParams.getAll("category").length > 0 ||
|
||||
searchParams.getAll("accountCard").length > 0 ||
|
||||
searchParams.get("settled") ||
|
||||
searchParams.get("hasAttachment") ||
|
||||
searchParams.get("isDivided") ||
|
||||
searchParams.get(AMOUNT_MIN_PARAM) ||
|
||||
searchParams.get(AMOUNT_MAX_PARAM);
|
||||
const hasDateRangeFilter =
|
||||
Boolean(searchParams.get(DATE_START_PARAM)) ||
|
||||
Boolean(searchParams.get(DATE_END_PARAM));
|
||||
const hasAmountFilter =
|
||||
Boolean(searchParams.get(AMOUNT_MIN_PARAM)) ||
|
||||
Boolean(searchParams.get(AMOUNT_MAX_PARAM));
|
||||
const activeFilterCount = [
|
||||
Boolean(searchParams.get("type")),
|
||||
searchParams.getAll("condition").length > 0,
|
||||
searchParams.getAll("payment").length > 0,
|
||||
searchParams.getAll("payer").length > 0,
|
||||
searchParams.getAll("category").length > 0,
|
||||
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 = () => {
|
||||
handleReset();
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -607,182 +662,234 @@ export function TransactionsFilters({
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
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>
|
||||
<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-2">
|
||||
<label className="text-sm font-medium">
|
||||
Condição de Lançamento
|
||||
</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">
|
||||
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-2">
|
||||
<label className="text-sm font-medium">
|
||||
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">
|
||||
Forma de pagamento
|
||||
</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={paymentOptions}
|
||||
selected={getParamValues("payment")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payment", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">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">
|
||||
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-2">
|
||||
<label className="text-sm font-medium">Categoria</label>
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={categoryMultiOptions}
|
||||
selected={getParamValues("category")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("category", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
searchable
|
||||
searchPlaceholder="Buscar categoria..."
|
||||
/>
|
||||
</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..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">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 className="space-y-2">
|
||||
<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 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">
|
||||
<p className="text-sm font-medium">Status</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-pago"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Somente pagos
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Período
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-pago"
|
||||
checked={
|
||||
searchParams.get("settled") ===
|
||||
SETTLED_FILTER_VALUES.PAID
|
||||
}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange(
|
||||
"settled",
|
||||
checked ? SETTLED_FILTER_VALUES.PAID : null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{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="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="filter-nao-pago"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Somente não pagos
|
||||
</label>
|
||||
<Switch
|
||||
id="filter-nao-pago"
|
||||
checked={
|
||||
searchParams.get("settled") ===
|
||||
SETTLED_FILTER_VALUES.UNPAID
|
||||
<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}
|
||||
onCheckedChange={(checked) => {
|
||||
handleFilterChange(
|
||||
"settled",
|
||||
checked ? SETTLED_FILTER_VALUES.UNPAID : null,
|
||||
);
|
||||
}}
|
||||
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">
|
||||
@@ -824,14 +931,27 @@ export function TransactionsFilters({
|
||||
</div>
|
||||
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetFilters}
|
||||
disabled={isPending || !hasActiveFilters}
|
||||
>
|
||||
Limpar filtros
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? `${activeFilterCount} ${
|
||||
activeFilterCount === 1
|
||||
? "filtro ativo"
|
||||
: "filtros ativos"
|
||||
}`
|
||||
: "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>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -43,15 +43,34 @@ const loadPdfDeps = async () => {
|
||||
return { jsPDF, autoTable };
|
||||
};
|
||||
|
||||
const formatPeriodDate = (dateString: string) =>
|
||||
formatDateOnly(dateString, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}) ?? dateString;
|
||||
|
||||
export function TransactionsExport({
|
||||
lancamentos,
|
||||
period,
|
||||
exportContext,
|
||||
}: TransactionsExportProps) {
|
||||
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) => {
|
||||
return `lancamentos-${period}.${extension}`;
|
||||
return `lancamentos-${filePeriodSlug}.${extension}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@@ -251,7 +270,7 @@ export function TransactionsExport({
|
||||
doc.text("Lançamentos", titleX, 15);
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Período: ${displayPeriod(period)}`, titleX, 22);
|
||||
doc.text(`Período: ${periodLabel}`, titleX, 22);
|
||||
doc.text(
|
||||
`Gerado em: ${
|
||||
formatDateTime(new Date(), {
|
||||
|
||||
@@ -33,3 +33,5 @@ export const SETTLED_FILTER_VALUES = {
|
||||
|
||||
export const AMOUNT_MIN_PARAM = "valorMin";
|
||||
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;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
dateStartFilter: string | null;
|
||||
dateEndFilter: string | null;
|
||||
};
|
||||
|
||||
export type TransactionsExportContext = {
|
||||
|
||||
@@ -22,6 +22,8 @@ import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import {
|
||||
AMOUNT_MAX_PARAM,
|
||||
AMOUNT_MIN_PARAM,
|
||||
DATE_END_PARAM,
|
||||
DATE_START_PARAM,
|
||||
PAYMENT_METHODS,
|
||||
SETTLED_FILTER_VALUES,
|
||||
TRANSACTION_CONDITIONS,
|
||||
@@ -38,7 +40,7 @@ import {
|
||||
PAYER_ROLE_ADMIN,
|
||||
PAYER_ROLE_THIRD_PARTY,
|
||||
} from "@/shared/lib/payers/constants";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { parseLocalDateString, toDateOnlyString } from "@/shared/utils/date";
|
||||
import { slugify } from "@/shared/utils/string";
|
||||
|
||||
type PayerRow = typeof payers.$inferSelect;
|
||||
@@ -66,6 +68,8 @@ export type TransactionSearchFilters = {
|
||||
dividedFilter: string | null;
|
||||
amountMinFilter: number | null;
|
||||
amountMaxFilter: number | null;
|
||||
dateStartFilter: string | null;
|
||||
dateEndFilter: string | null;
|
||||
};
|
||||
|
||||
type BaseSluggedOption = {
|
||||
@@ -162,6 +166,14 @@ export const parsePositiveAmount = (value: string | null): number | null => {
|
||||
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 = (
|
||||
params: ResolvedSearchParams,
|
||||
): TransactionSearchFilters => ({
|
||||
@@ -181,6 +193,10 @@ export const extractTransactionSearchFilters = (
|
||||
amountMaxFilter: parsePositiveAmount(
|
||||
getSingleParam(params, AMOUNT_MAX_PARAM),
|
||||
),
|
||||
dateStartFilter: parseDateFilterParam(
|
||||
getSingleParam(params, DATE_START_PARAM),
|
||||
),
|
||||
dateEndFilter: parseDateFilterParam(getSingleParam(params, DATE_END_PARAM)),
|
||||
});
|
||||
|
||||
export const resolveTransactionPagination = (
|
||||
@@ -377,10 +393,29 @@ export const buildTransactionWhere = ({
|
||||
accountId?: string;
|
||||
payerId?: string;
|
||||
}): SQL[] => {
|
||||
const where: SQL[] = [
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.period, period),
|
||||
];
|
||||
const where: SQL[] = [eq(transactions.userId, userId)];
|
||||
|
||||
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) {
|
||||
where.push(eq(transactions.payerId, payerId));
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface DatePickerProps {
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
/** Show compact format like "10 mar" instead of "10 de março de 2025" */
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -87,6 +88,7 @@ export function DatePicker({
|
||||
required = false,
|
||||
disabled = false,
|
||||
className,
|
||||
inputClassName,
|
||||
compact = false,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -140,7 +142,7 @@ export function DatePicker({
|
||||
id={id}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-background pr-10"
|
||||
className={cn("bg-background pr-10", inputClassName)}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
required={required}
|
||||
|
||||
Reference in New Issue
Block a user