mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(lancamentos): refina filtros e tabela responsiva
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -57,6 +57,7 @@ export type TransactionFilterOption = {
|
||||
label: string;
|
||||
icon?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
type?: string | null;
|
||||
};
|
||||
|
||||
export type AccountCardFilterOption = TransactionFilterOption & {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user