feat(lancamentos): refina filtros e tabela responsiva

This commit is contained in:
Felipe Coutinho
2026-05-28 10:59:24 -03:00
parent 311369f81b
commit 0171b0ce2f
9 changed files with 715 additions and 234 deletions

View File

@@ -50,7 +50,9 @@ export function buildReadOnlyOptionSets(
categoriaOptionsMap.set(item.categoryId, {
value: item.categoryId,
label: normalizeOptionLabel(item.categoriaName, "Category"),
group: item.categoriaType,
slug: item.categoryId,
icon: item.categoriaIcon,
});
}
});
@@ -67,6 +69,8 @@ export function buildReadOnlyOptionSets(
(option) => ({
slug: option.value,
label: option.label,
type: option.group,
icon: option.icon,
}),
);

View File

@@ -0,0 +1,157 @@
"use client";
import {
RiCheckLine,
RiDeleteBin5Line,
RiFileCopyLine,
RiFileList2Line,
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiRefundLine,
RiTimeLine,
} from "@remixicon/react";
import { Button } from "@/shared/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import type { TransactionItem } from "../types";
type TransactionActionsMenuProps = {
item: TransactionItem;
currentUserId: string;
onEdit?: (item: TransactionItem) => void;
onCopy?: (item: TransactionItem) => void;
onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
};
export function TransactionActionsMenu({
item,
currentUserId,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onAnticipate,
onViewAnticipationHistory,
}: TransactionActionsMenuProps) {
const isOwnData = item.userId === currentUserId;
const canRefund =
isOwnData &&
item.transactionType === "Despesa" &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX);
const showInstallmentActions =
isOwnData && item.condition === "Parcelado" && item.seriesId;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<RiMoreFill className="size-4" aria-hidden />
<span className="sr-only">Abrir ações do lançamento</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onSelect={() => onViewDetails?.(item)}
disabled={!onViewDetails}
>
<RiFileList2Line className="size-4" aria-hidden />
Detalhes
</DropdownMenuItem>
{isOwnData ? (
<DropdownMenuItem
onSelect={() => onEdit?.(item)}
disabled={item.readonly || !onEdit}
>
<RiPencilLine className="size-4" aria-hidden />
Editar
</DropdownMenuItem>
) : null}
{!item.readonly && isOwnData ? (
<DropdownMenuItem onSelect={() => onCopy?.(item)} disabled={!onCopy}>
<RiFileCopyLine className="size-4" aria-hidden />
Copiar
</DropdownMenuItem>
) : null}
{!item.readonly && !isOwnData ? (
<DropdownMenuItem
onSelect={() => onImport?.(item)}
disabled={!onImport}
>
<RiFileCopyLine className="size-4" aria-hidden />
Importar para Minha Conta
</DropdownMenuItem>
) : null}
{canRefund ? (
<DropdownMenuItem
onSelect={() => onRefund?.(item)}
disabled={!onRefund}
>
<RiRefundLine className="size-4" aria-hidden />
Reembolso
</DropdownMenuItem>
) : null}
{isOwnData ? (
<DropdownMenuItem
variant="destructive"
onSelect={() => onConfirmDelete?.(item)}
disabled={item.readonly || !onConfirmDelete}
>
<RiDeleteBin5Line className="size-4" aria-hidden />
Remover
</DropdownMenuItem>
) : null}
{showInstallmentActions ? (
<>
<DropdownMenuSeparator />
{!item.isAnticipated && onAnticipate ? (
<DropdownMenuItem onSelect={() => onAnticipate(item)}>
<RiTimeLine className="size-4" aria-hidden />
Antecipar Parcelas
</DropdownMenuItem>
) : null}
{onViewAnticipationHistory ? (
<DropdownMenuItem
onSelect={() => onViewAnticipationHistory(item)}
>
<RiHistoryLine className="size-4" aria-hidden />
Histórico de Antecipações
</DropdownMenuItem>
) : null}
{item.isAnticipated ? (
<DropdownMenuItem disabled>
<RiCheckLine className="size-4 text-success" aria-hidden />
Parcela Antecipada
</DropdownMenuItem>
) : null}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import {
RiBankCard2Line,
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
} from "@remixicon/react";
import {
CREDIT_CARD_PAYMENT_METHOD,
SETTLEABLE_PAYMENT_METHODS,
} from "@/features/transactions/lib/constants";
import { Button } from "@/shared/components/ui/button";
import { Spinner } from "@/shared/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
type TransactionSettlementButtonProps = {
item: TransactionItem;
isLoading: boolean;
onToggle?: (item: TransactionItem) => void;
};
export function TransactionSettlementButton({
item,
isLoading,
onToggle,
}: TransactionSettlementButtonProps) {
const isCreditCard = item.paymentMethod === CREDIT_CARD_PAYMENT_METHOD;
const canToggleSettlement = (
SETTLEABLE_PAYMENT_METHODS as readonly string[]
).includes(item.paymentMethod);
if (!canToggleSettlement && !isCreditCard) {
return null;
}
if (isCreditCard) {
const invoicePaid = Boolean(item.isSettled);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon-sm"
disabled
className={cn(
"transition-colors",
invoicePaid
? "bg-success/10 text-success"
: "text-muted-foreground/30",
)}
>
{invoicePaid ? (
<RiCheckboxCircleFill className="size-4" aria-hidden />
) : (
<RiBankCard2Line className="size-4" aria-hidden />
)}
<span className="sr-only">
{invoicePaid
? "Fatura paga"
: "Lançamento de cartão de crédito"}
</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 text-center">
{invoicePaid
? "Fatura paga"
: "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"}
</TooltipContent>
</Tooltip>
);
}
const settled = Boolean(item.isSettled);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onToggle?.(item)}
disabled={isLoading || item.readonly}
className={cn(
"transition-colors",
settled
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
: "text-muted-foreground hover:text-foreground",
)}
>
{isLoading ? (
<Spinner className="size-4" />
) : settled ? (
<RiCheckboxCircleFill className="size-4" aria-hidden />
) : (
<RiCheckboxBlankCircleLine className="size-4" aria-hidden />
)}
<span className="sr-only">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,28 +1,13 @@
import {
RiAttachment2,
RiBankCard2Line,
RiChat1Line,
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
RiCheckLine,
RiDeleteBin5Line,
RiFileCopyLine,
RiFileList2Line,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiRefundLine,
RiTimeLine,
} from "@remixicon/react";
import type { ColumnDef } from "@tanstack/react-table";
import Image from "next/image";
import Link from "next/link";
import { DEFAULT_TRANSACTIONS_COLUMN_ORDER } from "@/features/transactions/lib/column-order";
import {
CREDIT_CARD_PAYMENT_METHOD,
SETTLEABLE_PAYMENT_METHODS,
} from "@/features/transactions/lib/constants";
import {
CategoryIconBadge,
EstablishmentLogo,
@@ -35,28 +20,20 @@ import {
AvatarImage,
} from "@/shared/components/ui/avatar";
import { Badge } from "@/shared/components/ui/badge";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { Spinner } from "@/shared/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { REFUND_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
import { resolveLogoSrc } from "@/shared/lib/logo";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { formatDate } from "@/shared/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
import { TransactionActionsMenu } from "./transaction-actions-menu";
import { TransactionSettlementButton } from "./transaction-settlement-button";
type BuildColumnsArgs = {
currentUserId: string;
@@ -551,195 +528,23 @@ function buildColumns({
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
{(() => {
const paymentMethod = row.original.paymentMethod;
const isCreditCard = paymentMethod === CREDIT_CARD_PAYMENT_METHOD;
const canToggleSettlement = (
SETTLEABLE_PAYMENT_METHODS as readonly string[]
).includes(paymentMethod);
if (!canToggleSettlement && !isCreditCard) return null;
if (isCreditCard) {
const invoicePaid = Boolean(row.original.isSettled);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="icon-sm"
disabled
className={cn(
"transition-colors",
invoicePaid
? "bg-success/10 text-success"
: "text-muted-foreground/30",
)}
>
{invoicePaid ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiBankCard2Line className="size-4" />
)}
<span className="sr-only">
{invoicePaid
? "Fatura paga"
: "Lançamento de cartão de crédito"}
</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-48 text-center">
{invoicePaid
? "Fatura paga"
: "Lançamentos de cartão de crédito são liquidados ao pagar a fatura"}
</TooltipContent>
</Tooltip>
);
}
const readOnly = row.original.readonly;
const loading = isSettlementLoading(row.original.id);
const settled = Boolean(row.original.isSettled);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleToggleSettlement(row.original)}
disabled={loading || readOnly}
className={cn(
"transition-colors",
settled
? "bg-success/10 text-success hover:bg-success/20 hover:text-success"
: "text-muted-foreground hover:text-foreground",
)}
>
{loading ? (
<Spinner className="size-4" />
) : settled ? (
<RiCheckboxCircleFill className="size-4" />
) : (
<RiCheckboxBlankCircleLine className="size-4" />
)}
<span className="sr-only">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</TooltipContent>
</Tooltip>
);
})()}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm">
<RiMoreFill className="size-4" />
<span className="sr-only">Abrir ações do lançamento</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
onSelect={() => handleViewDetails(row.original)}
>
<RiFileList2Line className="size-4" />
Detalhes
</DropdownMenuItem>
{row.original.userId === currentUserId && (
<DropdownMenuItem
onSelect={() => handleEdit(row.original)}
disabled={row.original.readonly}
>
<RiPencilLine className="size-4" />
Editar
</DropdownMenuItem>
)}
{!row.original.readonly &&
row.original.userId === currentUserId && (
<DropdownMenuItem onSelect={() => handleCopy(row.original)}>
<RiFileCopyLine className="size-4" />
Copiar
</DropdownMenuItem>
)}
{!row.original.readonly &&
row.original.userId !== currentUserId && (
<DropdownMenuItem onSelect={() => handleImport(row.original)}>
<RiFileCopyLine className="size-4" />
Importar para Minha Conta
</DropdownMenuItem>
)}
{(() => {
const item = row.original;
const canRefund =
item.userId === currentUserId &&
item.transactionType === "Despesa" &&
item.condition === "À vista" &&
!item.splitGroupId &&
!item.readonly &&
!item.note?.startsWith(REFUND_NOTE_PREFIX);
if (!canRefund) return null;
return (
<DropdownMenuItem onSelect={() => handleRefund(item)}>
<RiRefundLine className="size-4" />
Reembolso
</DropdownMenuItem>
);
})()}
{row.original.userId === currentUserId && (
<DropdownMenuItem
variant="destructive"
onSelect={() => handleConfirmDelete(row.original)}
disabled={row.original.readonly}
>
<RiDeleteBin5Line className="size-4" />
Remover
</DropdownMenuItem>
)}
{row.original.userId === currentUserId &&
row.original.condition === "Parcelado" &&
row.original.seriesId && (
<>
<DropdownMenuSeparator />
{!row.original.isAnticipated && onAnticipate && (
<DropdownMenuItem
onSelect={() => handleAnticipate(row.original)}
>
<RiTimeLine className="size-4" />
Antecipar Parcelas
</DropdownMenuItem>
)}
{onViewAnticipationHistory && (
<DropdownMenuItem
onSelect={() =>
handleViewAnticipationHistory(row.original)
}
>
<RiHistoryLine className="size-4" />
Histórico de Antecipações
</DropdownMenuItem>
)}
{row.original.isAnticipated && (
<DropdownMenuItem disabled>
<RiCheckLine className="size-4 text-success" />
Parcela Antecipada
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<TransactionSettlementButton
item={row.original}
isLoading={isSettlementLoading(row.original.id)}
onToggle={handleToggleSettlement}
/>
<TransactionActionsMenu
item={row.original}
currentUserId={currentUserId}
onEdit={handleEdit}
onCopy={handleCopy}
onImport={handleImport}
onConfirmDelete={handleConfirmDelete}
onViewDetails={handleViewDetails}
onRefund={handleRefund}
onAnticipate={handleAnticipate}
onViewAnticipationHistory={handleViewAnticipationHistory}
/>
</div>
),
});

View File

@@ -67,6 +67,7 @@ import {
SelectTrigger,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import { Spinner } from "@/shared/components/ui/spinner";
import { Switch } from "@/shared/components/ui/switch";
import {
ToggleGroup,
@@ -193,6 +194,16 @@ type MultiOption = {
render?: ReactNode;
};
const getCategoryFilterGroup = (type?: string | null) => {
if (type === "receita") {
return "Receitas";
}
if (type === "despesa") {
return "Despesas";
}
return "Outras";
};
interface MultiSelectFilterProps {
placeholder: string;
options: MultiOption[];
@@ -290,7 +301,10 @@ function MultiSelectFilter({
>
{triggerLabel}
</span>
<RiExpandUpDownLine className="ml-2 size-4 shrink-0 opacity-50" />
<RiExpandUpDownLine
className="ml-2 size-4 shrink-0 opacity-50"
aria-hidden
/>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[260px] p-0">
@@ -331,7 +345,10 @@ function MultiSelectFilter({
{option.render ?? option.label}
</span>
{isSelected ? (
<RiCheckLine className="ml-auto size-4 shrink-0" />
<RiCheckLine
className="ml-auto size-4 shrink-0"
aria-hidden
/>
) : null}
</CommandItem>
);
@@ -518,6 +535,7 @@ export function TransactionsFilters({
categoryOptions.map((option) => ({
value: option.slug,
label: option.label,
group: getCategoryFilterGroup(option.type),
render: (
<CategorySelectContent label={option.label} icon={option.icon} />
),
@@ -585,6 +603,7 @@ export function TransactionsFilters({
return (
<div
aria-busy={isPending}
className={cn(
"flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center",
className,
@@ -608,7 +627,7 @@ export function TransactionsFilters({
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" />
<RiCloseLine className="size-4" aria-hidden />
</button>
) : null}
</div>
@@ -630,12 +649,19 @@ export function TransactionsFilters({
<Button
variant="outline"
className="flex-1 md:flex-none text-sm border-dashed relative bg-transparent"
aria-label="Abrir filtros"
aria-label={isPending ? "Aplicando filtros" : "Abrir filtros"}
>
<RiFilterLine className="size-4" />
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" />
<span
className="absolute -top-1 -right-1 size-3 rounded-full bg-primary"
aria-hidden
/>
)}
</Button>
</DrawerTrigger>
@@ -649,7 +675,7 @@ export function TransactionsFilters({
aria-label="Limpar filtros"
className="text-xs text-muted-foreground hover:text-foreground h-9 px-2"
>
<RiCloseLine className="size-3.5" />
<RiCloseLine className="size-3.5" aria-hidden />
Limpar
</Button>
)}
@@ -746,6 +772,7 @@ export function TransactionsFilters({
disabled={isPending}
searchable
searchPlaceholder="Buscar categoria..."
groupOrder={["Despesas", "Receitas", "Outras"]}
/>
</div>
@@ -775,7 +802,7 @@ export function TransactionsFilters({
<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
Intervalo de datas
</label>
{hasDateRangeFilter ? (
<button
@@ -932,15 +959,33 @@ export function TransactionsFilters({
<DrawerFooter>
<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>
<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"

View File

@@ -0,0 +1,328 @@
"use client";
import {
RiArrowLeftRightLine,
RiArrowRightDownLine,
RiArrowRightUpLine,
RiAttachment2,
RiCalendarEventLine,
RiChat1Line,
RiGroupLine,
RiTimeLine,
} from "@remixicon/react";
import Image from "next/image";
import type { ReactNode } from "react";
import { EstablishmentLogo } from "@/shared/components/entity-avatar";
import MoneyValues from "@/shared/components/money-values";
import { Badge } from "@/shared/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { formatDate } from "@/shared/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/shared/utils/icons";
import { cn } from "@/shared/utils/ui";
import type { TransactionItem } from "../types";
import { TransactionActionsMenu } from "./transaction-actions-menu";
import { TransactionSettlementButton } from "./transaction-settlement-button";
type TransactionsMobileListProps = {
data: TransactionItem[];
currentUserId: string;
onEdit?: (item: TransactionItem) => void;
onCopy?: (item: TransactionItem) => void;
onImport?: (item: TransactionItem) => void;
onConfirmDelete?: (item: TransactionItem) => void;
onViewDetails?: (item: TransactionItem) => void;
onRefund?: (item: TransactionItem) => void;
onToggleSettlement?: (item: TransactionItem) => void;
onAnticipate?: (item: TransactionItem) => void;
onViewAnticipationHistory?: (item: TransactionItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions?: boolean;
};
export function TransactionsMobileList({
data,
currentUserId,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions = true,
}: TransactionsMobileListProps) {
return (
<div className="space-y-3 md:hidden">
{data.map((item) => (
<TransactionMobileCard
key={item.id}
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
isSettlementLoading={isSettlementLoading}
showActions={showActions}
/>
))}
</div>
);
}
type TransactionMobileCardProps = Omit<TransactionsMobileListProps, "data"> & {
item: TransactionItem;
};
function TransactionMobileCard({
item,
currentUserId,
onEdit,
onCopy,
onImport,
onConfirmDelete,
onViewDetails,
onRefund,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions = true,
}: TransactionMobileCardProps) {
const installmentBadge =
item.currentInstallment && item.installmentCount
? `${item.currentInstallment} de ${item.installmentCount}`
: null;
const isBoleto = item.paymentMethod === "Boleto" && item.dueDate;
const dueDateLabel =
isBoleto && item.dueDate ? `Venc. ${formatDate(item.dueDate)}` : null;
const hasNote = Boolean(item.note?.trim().length);
const isLastInstallment =
item.currentInstallment === item.installmentCount &&
item.installmentCount &&
item.installmentCount > 1;
const isReceita = item.transactionType === "Receita";
const isTransfer = item.transactionType === "Transferência";
const isIncomingTransfer = isTransfer && Number(item.amount) > 0;
const payerLabel = item.pagadorName?.trim() || "Sem pessoa";
const payerDisplayName = payerLabel.split(/\s+/)[0] ?? payerLabel;
const paymentMethodLabel =
item.paymentMethod === "Transferência bancária"
? "Transf. bancária"
: item.paymentMethod;
const type =
item.categoriaName === "Saldo inicial"
? "Saldo inicial"
: item.transactionType;
return (
<article
className={cn(
"rounded-md border bg-card px-3 py-2.5 shadow-xs",
item.paymentMethod === "Boleto" &&
item.dueDate &&
!item.isSettled &&
new Date(item.dueDate) < new Date() &&
"border-destructive/20 bg-destructive/3",
)}
>
<div className="flex items-center gap-2.5">
<EstablishmentLogo name={item.name} size={34} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold leading-tight">
{item.name}
</h3>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<RiCalendarEventLine className="size-3.5" aria-hidden />
{formatDate(item.purchaseDate)}
</span>
{dueDateLabel ? (
<span className="font-medium text-primary">
{dueDateLabel}
</span>
) : null}
<span className="truncate">{payerDisplayName}</span>
</div>
</div>
<div className="shrink-0 text-right">
<MoneyValues
amount={item.amount}
showPositiveSign={isReceita || isIncomingTransfer}
className={cn(
"whitespace-nowrap text-sm font-semibold",
isReceita ? "text-success" : "text-foreground",
isTransfer && "text-info",
)}
/>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<IconBadge
label={type}
compact
className={getTransactionTypeIconClassName(type)}
>
{getTransactionTypeIcon(type)}
</IconBadge>
<IconBadge label={paymentMethodLabel} compact>
{getPaymentMethodIcon(item.paymentMethod)}
</IconBadge>
<IconBadge label={item.condition} compact>
{getConditionIcon(item.condition)}
</IconBadge>
{installmentBadge ? (
<Badge variant="outline" className="px-1.5 text-xs">
{installmentBadge}
</Badge>
) : null}
{item.isDivided ? (
<IconBadge label="Dividido entre pessoas" compact>
<RiGroupLine className="size-3.5" aria-hidden />
</IconBadge>
) : null}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex size-6 items-center justify-center rounded-full border text-muted-foreground">
<Image
src="/icons/party.svg"
alt=""
width={14}
height={14}
className="size-3.5"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{item.isAnticipated ? (
<IconBadge label="Parcela antecipada" compact>
<RiTimeLine className="size-3.5" aria-hidden />
</IconBadge>
) : null}
{hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex size-6 items-center justify-center rounded-full border text-muted-foreground">
<RiChat1Line className="size-3.5" aria-hidden />
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line"
>
{item.note}
</TooltipContent>
</Tooltip>
) : null}
{item.hasAttachments ? (
<IconBadge label="Possui anexos" compact>
<RiAttachment2 className="size-3.5" aria-hidden />
</IconBadge>
) : null}
</div>
{showActions ? (
<div className="flex shrink-0 items-center gap-1">
<TransactionSettlementButton
item={item}
isLoading={isSettlementLoading(item.id)}
onToggle={onToggleSettlement}
/>
<TransactionActionsMenu
item={item}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
/>
</div>
) : null}
</div>
</div>
</div>
</article>
);
}
function IconBadge({
label,
children,
compact = false,
className,
}: {
label: string;
children: ReactNode;
compact?: boolean;
className?: string;
}) {
if (!children) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center rounded-full border text-xs text-muted-foreground",
compact ? "size-6 justify-center" : "gap-1 px-2 py-0.5",
className,
)}
>
{children}
{compact ? <span className="sr-only">{label}</span> : label}
</span>
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
}
function getTransactionTypeIcon(type: string) {
if (type === "Receita" || type === "Saldo inicial") {
return <RiArrowRightDownLine className="size-3.5" aria-hidden />;
}
if (type === "Transferência") {
return <RiArrowLeftRightLine className="size-3.5" aria-hidden />;
}
return <RiArrowRightUpLine className="size-3.5" aria-hidden />;
}
function getTransactionTypeIconClassName(type: string) {
if (type === "Receita" || type === "Saldo inicial") {
return "border-success/30 bg-success/5 text-success";
}
if (type === "Transferência") {
return "border-info/30 bg-info/5 text-info";
}
return "border-destructive/30 bg-destructive/5 text-destructive";
}

View File

@@ -47,6 +47,7 @@ import type {
import { TransactionsBulkBar } from "./transactions-bulk-bar";
import { getTransactionColumns } from "./transactions-columns";
import { TransactionsFilters } from "./transactions-filters";
import { TransactionsMobileList } from "./transactions-mobile-list";
import { TransactionsPagination } from "./transactions-pagination";
type TransactionsTableProps = {
@@ -349,7 +350,23 @@ export function TransactionsTable({
<CardContent className="px-2 py-4 sm:px-4">
{hasRows ? (
<>
<div className="overflow-x-auto">
<TransactionsMobileList
data={rowModel.rows.map((row) => row.original)}
currentUserId={currentUserId}
onEdit={onEdit}
onCopy={onCopy}
onImport={onImport}
onConfirmDelete={onConfirmDelete}
onViewDetails={onViewDetails}
onRefund={onRefund}
onToggleSettlement={onToggleSettlement}
onAnticipate={onAnticipate}
onViewAnticipationHistory={onViewAnticipationHistory}
isSettlementLoading={isSettlementLoading ?? (() => false)}
showActions={showActions}
/>
<div className="hidden overflow-x-auto md:block">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

View File

@@ -57,6 +57,7 @@ export type TransactionFilterOption = {
label: string;
icon?: string | null;
avatarUrl?: string | null;
type?: string | null;
};
export type AccountCardFilterOption = TransactionFilterOption & {

View File

@@ -118,6 +118,9 @@ export type SlugMaps = {
type FilterOption = {
slug: string;
label: string;
icon?: string | null;
avatarUrl?: string | null;
type?: string | null;
};
type AccountCardFilterOption = FilterOption & {
@@ -686,7 +689,12 @@ export const buildOptionSets = ({
);
const categoryFilterOptions = sortByLabel(
categoryFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon })),
categoryFiltersRaw.map(({ slug, label, type, icon }) => ({
slug,
label,
type,
icon,
})),
);
const accountCardFilterOptions = sortByLabel(