mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-10 07:16:01 +00:00
chore: prepara versão 2.5.5
Filtros multi-seleção em lançamentos (condição, forma de pagamento, pessoa, categoria, conta/cartão), changelog redesenhado como timeline colapsável com detecção de bump e resumo, e diálogos migrados para as animações utilitárias do tw-animate-css. Inclui ajustes de label no BulkActionDialog, refinamentos visuais na landing page e atualização da navbar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -116,10 +116,10 @@ export function BulkActionDialog({
|
||||
htmlFor="period"
|
||||
className="text-sm cursor-pointer font-medium"
|
||||
>
|
||||
Todas as pessoas deste período
|
||||
{`Todas as pessoas desta parcela (${currentNumber}/${totalCount})`}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Aplica a todos os lançamentos deste mesmo mês na série
|
||||
Aplica a alteração para todas as pessoas que dividem esta parcela
|
||||
</p>
|
||||
{scope === "period" && actionType === "edit" && (
|
||||
<div className="mt-1.5 flex items-start gap-1.5 rounded-md bg-amber-50 px-2 py-1.5 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RiAddFill } from "@remixicon/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
getPresignedUploadUrlAction,
|
||||
} from "@/features/transactions/actions/attachments";
|
||||
import { ConfirmActionDialog } from "@/shared/components/confirm-action-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import type {
|
||||
TransactionsExportContext,
|
||||
TransactionsPaginationState,
|
||||
@@ -115,7 +117,6 @@ export function TransactionsPage({
|
||||
const [selectedTransaction, setSelectedTransaction] =
|
||||
useState<TransactionItem | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [transactionToCopy, setTransactionToCopy] =
|
||||
useState<TransactionItem | null>(null);
|
||||
@@ -411,15 +412,6 @@ export function TransactionsPage({
|
||||
setPendingMultipleDeleteData([]);
|
||||
};
|
||||
|
||||
const [transactionTypeForCreate, setTransactionTypeForCreate] = useState<
|
||||
"Despesa" | "Receita" | null
|
||||
>(null);
|
||||
|
||||
const handleCreate = (type: "Despesa" | "Receita") => {
|
||||
setTransactionTypeForCreate(type);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleMassAdd = () => {
|
||||
setMassAddOpen(true);
|
||||
};
|
||||
@@ -558,6 +550,57 @@ export function TransactionsPage({
|
||||
setAnticipationHistoryOpen(true);
|
||||
};
|
||||
|
||||
const createSlot = allowCreate ? (
|
||||
<>
|
||||
<TransactionDialog
|
||||
mode="create"
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
defaultPayerId={defaultPayerId}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
defaultPeriod={selectedPeriod}
|
||||
defaultCardId={defaultCardId}
|
||||
defaultPaymentMethod={defaultPaymentMethod}
|
||||
lockCardSelection={lockCardSelection}
|
||||
lockPaymentMethod={lockPaymentMethod}
|
||||
defaultTransactionType="Receita"
|
||||
maxSizeMb={attachmentMaxSizeMb}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddFill className="size-4" />
|
||||
Nova Receita
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TransactionDialog
|
||||
mode="create"
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
defaultPayerId={defaultPayerId}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
defaultPeriod={selectedPeriod}
|
||||
defaultCardId={defaultCardId}
|
||||
defaultPaymentMethod={defaultPaymentMethod}
|
||||
lockCardSelection={lockCardSelection}
|
||||
lockPaymentMethod={lockPaymentMethod}
|
||||
defaultTransactionType="Despesa"
|
||||
maxSizeMb={attachmentMaxSizeMb}
|
||||
trigger={
|
||||
<Button className="w-full sm:w-auto">
|
||||
<RiAddFill className="size-4" />
|
||||
Nova Despesa
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TransactionsTable
|
||||
@@ -571,7 +614,7 @@ export function TransactionsPage({
|
||||
selectedPeriod={selectedPeriod}
|
||||
pagination={pagination}
|
||||
exportContext={exportContext}
|
||||
onCreate={allowCreate ? handleCreate : undefined}
|
||||
createSlot={createSlot}
|
||||
onMassAdd={allowCreate ? handleMassAdd : undefined}
|
||||
onEdit={handleEdit}
|
||||
onCopy={handleCopy}
|
||||
@@ -587,28 +630,6 @@ export function TransactionsPage({
|
||||
isSettlementLoading={(id) => settlementLoadingId === id}
|
||||
/>
|
||||
|
||||
{allowCreate ? (
|
||||
<TransactionDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
payerOptions={payerOptions}
|
||||
splitPayerOptions={splitPayerOptions}
|
||||
defaultPayerId={defaultPayerId}
|
||||
accountOptions={accountOptions}
|
||||
cardOptions={cardOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
estabelecimentos={estabelecimentos}
|
||||
defaultPeriod={selectedPeriod}
|
||||
defaultCardId={defaultCardId}
|
||||
defaultPaymentMethod={defaultPaymentMethod}
|
||||
lockCardSelection={lockCardSelection}
|
||||
lockPaymentMethod={lockPaymentMethod}
|
||||
defaultTransactionType={transactionTypeForCreate ?? undefined}
|
||||
maxSizeMb={attachmentMaxSizeMb}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TransactionDialog
|
||||
mode="create"
|
||||
open={copyOpen && !!transactionToCopy}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiExpandUpDownLine,
|
||||
RiFilter3Line,
|
||||
} from "@remixicon/react";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/lib/constants";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -46,9 +49,7 @@ import {
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from "@/shared/components/ui/select";
|
||||
import { Switch } from "@/shared/components/ui/switch";
|
||||
@@ -127,6 +128,158 @@ function FilterSelect({
|
||||
);
|
||||
}
|
||||
|
||||
type MultiOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group?: string;
|
||||
render?: ReactNode;
|
||||
};
|
||||
|
||||
interface MultiSelectFilterProps {
|
||||
placeholder: string;
|
||||
options: MultiOption[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
widthClass?: string;
|
||||
disabled?: boolean;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
groupOrder?: string[];
|
||||
}
|
||||
|
||||
function MultiSelectFilter({
|
||||
placeholder,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
widthClass = "w-full",
|
||||
disabled,
|
||||
searchable = false,
|
||||
searchPlaceholder = "Buscar...",
|
||||
groupOrder,
|
||||
}: MultiSelectFilterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const groupedOptions = useMemo(() => {
|
||||
const map = new Map<string, MultiOption[]>();
|
||||
for (const option of options) {
|
||||
const key = option.group ?? "";
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(option);
|
||||
map.set(key, list);
|
||||
}
|
||||
const orderedKeys = groupOrder
|
||||
? [
|
||||
...groupOrder,
|
||||
...Array.from(map.keys()).filter((k) => !groupOrder.includes(k)),
|
||||
]
|
||||
: Array.from(map.keys());
|
||||
return orderedKeys
|
||||
.filter((key) => map.has(key))
|
||||
.map((key) => ({ name: key, items: map.get(key) ?? [] }));
|
||||
}, [options, groupOrder]);
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const selectedOptions = options.filter((option) =>
|
||||
selectedSet.has(option.value),
|
||||
);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (selectedSet.has(value)) {
|
||||
onChange(selected.filter((v) => v !== value));
|
||||
} else {
|
||||
onChange([...selected, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const triggerLabel: ReactNode =
|
||||
selectedOptions.length === 0 ? (
|
||||
placeholder
|
||||
) : selectedOptions.length === 1 ? (
|
||||
(selectedOptions[0]?.render ?? selectedOptions[0]?.label)
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-foreground">
|
||||
{selectedOptions.length} selecionados
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"justify-between text-sm border-dashed font-normal",
|
||||
widthClass,
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[260px] p-0">
|
||||
<Command>
|
||||
{searchable ? <CommandInput placeholder={searchPlaceholder} /> : null}
|
||||
<CommandList>
|
||||
<CommandEmpty>Nada encontrado.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__clear"
|
||||
onSelect={() => clear()}
|
||||
disabled={selectedOptions.length === 0}
|
||||
className="text-muted-foreground data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none"
|
||||
>
|
||||
Limpar seleção
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{groupedOptions.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.name || "default"}
|
||||
heading={group.name || undefined}
|
||||
>
|
||||
{group.items.map((option) => {
|
||||
const isSelected = selectedSet.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.value} ${option.label}`}
|
||||
onSelect={() => toggle(option.value)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0 truncate">
|
||||
{option.render ?? option.label}
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<RiCheckLine className="ml-auto size-4 shrink-0" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface TransactionsFiltersProps {
|
||||
payerOptions: TransactionFilterOption[];
|
||||
categoryOptions: TransactionFilterOption[];
|
||||
@@ -152,6 +305,11 @@ export function TransactionsFilters({
|
||||
const getParamValue = (key: string) =>
|
||||
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
|
||||
|
||||
const getParamValues = useCallback(
|
||||
(key: string) => searchParams.getAll(key),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string | null) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
@@ -174,6 +332,27 @@ export function TransactionsFilters({
|
||||
[searchParams, pathname, router],
|
||||
);
|
||||
|
||||
const handleMultiFilterChange = useCallback(
|
||||
(key: string, values: string[]) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
nextParams.delete(key);
|
||||
for (const value of values) {
|
||||
if (value) {
|
||||
nextParams.append(key, value);
|
||||
}
|
||||
}
|
||||
nextParams.delete("page");
|
||||
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
: pathname;
|
||||
router.replace(target, { scroll: false });
|
||||
});
|
||||
},
|
||||
[searchParams, pathname, router],
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
|
||||
const currentSearchParam = searchParams.get("q") ?? "";
|
||||
|
||||
@@ -205,7 +384,6 @@ export function TransactionsFilters({
|
||||
nextParams.set("pageSize", pageSizeValue);
|
||||
}
|
||||
setSearchValue("");
|
||||
setCategoryOpen(false);
|
||||
startTransition(() => {
|
||||
const target = nextParams.toString()
|
||||
? `${pathname}?${nextParams.toString()}`
|
||||
@@ -214,56 +392,79 @@ export function TransactionsFilters({
|
||||
});
|
||||
};
|
||||
|
||||
const payerSelectOptions = payerOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
avatarUrl: option.avatarUrl,
|
||||
}));
|
||||
const conditionOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
TRANSACTION_CONDITIONS.map((value) => ({
|
||||
value: slugify(value),
|
||||
label: value,
|
||||
render: <ConditionSelectContent label={value} />,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const accountOptions = accountCardOptions
|
||||
.filter((option) => option.kind === "conta")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
const paymentOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
PAYMENT_METHODS.map((value) => ({
|
||||
value: slugify(value),
|
||||
label: value,
|
||||
render: <PaymentMethodSelectContent label={value} />,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const cardOptions = accountCardOptions
|
||||
.filter((option) => option.kind === "cartao")
|
||||
.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
logo: option.logo,
|
||||
}));
|
||||
const payerMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
payerOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
render: (
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[payerOptions],
|
||||
);
|
||||
|
||||
const categoryValue = getParamValue("category");
|
||||
const selectedCategory =
|
||||
categoryValue !== FILTER_EMPTY_VALUE
|
||||
? categoryOptions.find((option) => option.slug === categoryValue)
|
||||
: null;
|
||||
const categoryMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
categoryOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
render: (
|
||||
<CategorySelectContent label={option.label} icon={option.icon} />
|
||||
),
|
||||
})),
|
||||
[categoryOptions],
|
||||
);
|
||||
|
||||
const payerValue = getParamValue("payer");
|
||||
const selectedPayer =
|
||||
payerValue !== FILTER_EMPTY_VALUE
|
||||
? payerOptions.find((option) => option.slug === payerValue)
|
||||
: null;
|
||||
const accountCardMultiOptions = useMemo<MultiOption[]>(
|
||||
() =>
|
||||
accountCardOptions.map((option) => ({
|
||||
value: option.slug,
|
||||
label: option.label,
|
||||
group: option.kind === "cartao" ? "Cartões" : "Contas",
|
||||
render: (
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={option.kind === "cartao"}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[accountCardOptions],
|
||||
);
|
||||
|
||||
const accountCardValue = getParamValue("accountCard");
|
||||
const selectedAccountCard =
|
||||
accountCardValue !== FILTER_EMPTY_VALUE
|
||||
? accountCardOptions.find((option) => option.slug === accountCardValue)
|
||||
: null;
|
||||
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchParams.get("type") ||
|
||||
searchParams.get("condition") ||
|
||||
searchParams.get("payment") ||
|
||||
searchParams.get("payer") ||
|
||||
searchParams.get("category") ||
|
||||
searchParams.get("accountCard") ||
|
||||
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");
|
||||
@@ -280,13 +481,28 @@ export function TransactionsFilters({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar"
|
||||
aria-label="Buscar lançamentos"
|
||||
className="w-full md:w-[250px] text-sm border-dashed"
|
||||
/>
|
||||
<div className="relative w-full md:w-[250px]">
|
||||
<Input
|
||||
value={searchValue}
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Buscar"
|
||||
aria-label="Buscar lançamentos"
|
||||
className={cn(
|
||||
"w-full text-sm border-dashed",
|
||||
searchValue.length > 0 && "pr-8",
|
||||
)}
|
||||
/>
|
||||
{searchValue.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchValue("")}
|
||||
aria-label="Limpar busca"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 md:w-auto">
|
||||
{exportButton && (
|
||||
@@ -348,20 +564,14 @@ export function TransactionsFilters({
|
||||
<label className="text-sm font-medium">
|
||||
Condição de Lançamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="condition"
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={TRANSACTION_CONDITIONS.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
options={conditionOptions}
|
||||
selected={getParamValues("condition")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("condition", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<ConditionSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -369,195 +579,61 @@ export function TransactionsFilters({
|
||||
<label className="text-sm font-medium">
|
||||
Forma de Pagamento
|
||||
</label>
|
||||
<FilterSelect
|
||||
param="payment"
|
||||
placeholder="Todos"
|
||||
options={PAYMENT_METHODS.map((v) => ({
|
||||
value: slugify(v),
|
||||
label: v,
|
||||
}))}
|
||||
widthClass="w-full border-dashed"
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={paymentOptions}
|
||||
selected={getParamValues("payment")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payment", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
getParamValue={getParamValue}
|
||||
onChange={handleFilterChange}
|
||||
renderContent={(label) => (
|
||||
<PaymentMethodSelectContent label={label} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Pessoa</label>
|
||||
<Select
|
||||
value={getParamValue("payer")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"payer",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
<MultiSelectFilter
|
||||
placeholder="Todas"
|
||||
options={payerMultiOptions}
|
||||
selected={getParamValues("payer")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("payer", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedPayer ? (
|
||||
<PayerSelectContent
|
||||
label={selectedPayer.label}
|
||||
avatarUrl={selectedPayer.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
"Todos"
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
|
||||
{payerSelectOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<PayerSelectContent
|
||||
label={option.label}
|
||||
avatarUrl={option.avatarUrl}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
searchable
|
||||
searchPlaceholder="Buscar pessoa..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Categoria</label>
|
||||
<Popover
|
||||
open={categoryOpen}
|
||||
onOpenChange={setCategoryOpen}
|
||||
modal
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={categoryOpen}
|
||||
className="w-full justify-between text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{selectedCategory ? (
|
||||
<CategorySelectContent
|
||||
label={selectedCategory.label}
|
||||
icon={selectedCategory.icon}
|
||||
/>
|
||||
) : (
|
||||
"Todas"
|
||||
)}
|
||||
</span>
|
||||
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[220px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar categoria..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nada encontrado.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={FILTER_EMPTY_VALUE}
|
||||
onSelect={() => {
|
||||
handleFilterChange("category", null);
|
||||
setCategoryOpen(false);
|
||||
}}
|
||||
>
|
||||
Todas
|
||||
{categoryValue === FILTER_EMPTY_VALUE ? (
|
||||
<RiCheckLine className="ml-auto size-4" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
{categoryOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.slug}
|
||||
value={option.slug}
|
||||
onSelect={() => {
|
||||
handleFilterChange("category", option.slug);
|
||||
setCategoryOpen(false);
|
||||
}}
|
||||
>
|
||||
<CategorySelectContent
|
||||
label={option.label}
|
||||
icon={option.icon}
|
||||
/>
|
||||
{categoryValue === option.slug ? (
|
||||
<RiCheckLine className="ml-auto size-4" />
|
||||
) : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<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>
|
||||
<Select
|
||||
value={getParamValue("accountCard")}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange(
|
||||
"accountCard",
|
||||
value === FILTER_EMPTY_VALUE ? null : value,
|
||||
)
|
||||
<MultiSelectFilter
|
||||
placeholder="Todos"
|
||||
options={accountCardMultiOptions}
|
||||
selected={getParamValues("accountCard")}
|
||||
onChange={(values) =>
|
||||
handleMultiFilterChange("accountCard", values)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full text-sm border-dashed"
|
||||
disabled={isPending}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedAccountCard ? (
|
||||
<AccountCardSelectContent
|
||||
label={selectedAccountCard.label}
|
||||
logo={selectedAccountCard.logo}
|
||||
isCartao={selectedAccountCard.kind === "cartao"}
|
||||
/>
|
||||
) : (
|
||||
"Todos"
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
|
||||
{accountOptions.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Contas</SelectLabel>
|
||||
{accountOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{cardOptions.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Cartões</SelectLabel>
|
||||
{cardOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<AccountCardSelectContent
|
||||
label={option.label}
|
||||
logo={option.logo}
|
||||
isCartao={true}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
searchable
|
||||
searchPlaceholder="Buscar conta ou cartão..."
|
||||
groupOrder={["Contas", "Cartões"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import {
|
||||
RiAddFill,
|
||||
RiArrowLeftRightLine,
|
||||
RiFileExcel2Line,
|
||||
RiFlashlightFill,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import type {
|
||||
TransactionsExportContext,
|
||||
TransactionsPaginationState,
|
||||
@@ -61,7 +60,7 @@ type TransactionsTableProps = {
|
||||
selectedPeriod?: string;
|
||||
pagination?: TransactionsPaginationState;
|
||||
exportContext?: TransactionsExportContext;
|
||||
onCreate?: (type: "Despesa" | "Receita") => void;
|
||||
createSlot?: ReactNode;
|
||||
onMassAdd?: () => void;
|
||||
onEdit?: (item: TransactionItem) => void;
|
||||
onCopy?: (item: TransactionItem) => void;
|
||||
@@ -90,7 +89,7 @@ export function TransactionsTable({
|
||||
selectedPeriod,
|
||||
pagination: serverPagination,
|
||||
exportContext,
|
||||
onCreate,
|
||||
createSlot,
|
||||
onMassAdd,
|
||||
onEdit,
|
||||
onCopy,
|
||||
@@ -253,32 +252,15 @@ export function TransactionsTable({
|
||||
};
|
||||
|
||||
const showTopControls =
|
||||
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
|
||||
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{showTopControls ? (
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
{onCreate || onMassAdd ? (
|
||||
{createSlot || onMassAdd ? (
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
{onCreate ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onCreate("Receita")}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RiAddFill className="size-4" />
|
||||
Nova Receita
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onCreate("Despesa")}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RiAddFill className="size-4" />
|
||||
Nova Despesa
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{createSlot}
|
||||
{onMassAdd ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
Reference in New Issue
Block a user