feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

@@ -0,0 +1,463 @@
"use client";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
type ReactNode,
} from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import { cn } from "@/lib/utils/ui";
import {
TransactionTypeSelectContent,
ConditionSelectContent,
PaymentMethodSelectContent,
CategoriaSelectContent,
PagadorSelectContent,
ContaCartaoSelectContent,
} from "../select-items";
import type { ContaCartaoFilterOption, LancamentoFilterOption } from "../types";
const FILTER_EMPTY_VALUE = "__all";
const buildStaticOptions = (values: readonly string[]) =>
values.map((value) => ({ value, label: value }));
interface FilterSelectProps {
param: string;
placeholder: string;
options: { value: string; label: string }[];
widthClass?: string;
disabled?: boolean;
getParamValue: (key: string) => string;
onChange: (key: string, value: string | null) => void;
renderContent?: (label: string) => ReactNode;
}
function FilterSelect({
param,
placeholder,
options,
widthClass = "w-[130px]",
disabled,
getParamValue,
onChange,
renderContent,
}: FilterSelectProps) {
const value = getParamValue(param);
const current = options.find((option) => option.value === value);
const displayLabel =
value === FILTER_EMPTY_VALUE ? placeholder : current?.label ?? placeholder;
return (
<Select
value={value}
onValueChange={(nextValue) =>
onChange(param, nextValue === FILTER_EMPTY_VALUE ? null : nextValue)
}
disabled={disabled}
>
<SelectTrigger
className={cn("text-sm border-dashed", widthClass)}
disabled={disabled}
>
<span className="truncate">
{value !== FILTER_EMPTY_VALUE && current && renderContent
? renderContent(current.label)
: displayLabel}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{renderContent ? renderContent(option.label) : option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
interface LancamentosFiltersProps {
pagadorOptions: LancamentoFilterOption[];
categoriaOptions: LancamentoFilterOption[];
contaCartaoOptions: ContaCartaoFilterOption[];
className?: string;
}
export function LancamentosFilters({
pagadorOptions,
categoriaOptions,
contaCartaoOptions,
className,
}: LancamentosFiltersProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const getParamValue = useCallback(
(key: string) => searchParams.get(key) ?? FILTER_EMPTY_VALUE,
[searchParams]
);
const handleFilterChange = useCallback(
(key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (value && value !== FILTER_EMPTY_VALUE) {
nextParams.set(key, value);
} else {
nextParams.delete(key);
}
startTransition(() => {
router.replace(`${pathname}?${nextParams.toString()}`, {
scroll: false,
});
});
},
[pathname, router, searchParams, startTransition]
);
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? "";
useEffect(() => {
setSearchValue(currentSearchParam);
}, [currentSearchParam]);
useEffect(() => {
if (searchValue === currentSearchParam) {
return;
}
const timeout = setTimeout(() => {
const normalized = searchValue.trim();
handleFilterChange("q", normalized.length > 0 ? normalized : null);
}, 350);
return () => clearTimeout(timeout);
}, [searchValue, currentSearchParam, handleFilterChange]);
const handleReset = useCallback(() => {
const periodValue = searchParams.get("periodo");
const nextParams = new URLSearchParams();
if (periodValue) {
nextParams.set("periodo", periodValue);
}
setSearchValue("");
setCategoriaOpen(false);
startTransition(() => {
const target = nextParams.toString()
? `${pathname}?${nextParams.toString()}`
: pathname;
router.replace(target, { scroll: false });
});
}, [pathname, router, searchParams, startTransition]);
const pagadorSelectOptions = useMemo(
() =>
pagadorOptions.map((option) => ({
value: option.slug,
label: option.label,
avatarUrl: option.avatarUrl,
})),
[pagadorOptions]
);
const contaOptions = useMemo(
() =>
contaCartaoOptions
.filter((option) => option.kind === "conta")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
})),
[contaCartaoOptions]
);
const cartaoOptions = useMemo(
() =>
contaCartaoOptions
.filter((option) => option.kind === "cartao")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
})),
[contaCartaoOptions]
);
const categoriaValue = getParamValue("categoria");
const selectedCategoria =
categoriaValue !== FILTER_EMPTY_VALUE
? categoriaOptions.find((option) => option.slug === categoriaValue)
: null;
const pagadorValue = getParamValue("pagador");
const selectedPagador =
pagadorValue !== FILTER_EMPTY_VALUE
? pagadorOptions.find((option) => option.slug === pagadorValue)
: null;
const contaCartaoValue = getParamValue("contaCartao");
const selectedContaCartao =
contaCartaoValue !== FILTER_EMPTY_VALUE
? contaCartaoOptions.find((option) => option.slug === contaCartaoValue)
: null;
const [categoriaOpen, setCategoriaOpen] = useState(false);
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<FilterSelect
param="transacao"
placeholder="Tipo de Lançamento"
options={buildStaticOptions(LANCAMENTO_TRANSACTION_TYPES)}
widthClass="w-[130px]"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => (
<TransactionTypeSelectContent label={label} />
)}
/>
<FilterSelect
param="condicao"
placeholder="Condição"
options={buildStaticOptions(LANCAMENTO_CONDITIONS)}
widthClass="w-[130px]"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => <ConditionSelectContent label={label} />}
/>
<FilterSelect
param="pagamento"
placeholder="Pagamento"
options={buildStaticOptions(LANCAMENTO_PAYMENT_METHODS)}
widthClass="w-[130px]"
disabled={isPending}
getParamValue={getParamValue}
onChange={handleFilterChange}
renderContent={(label) => <PaymentMethodSelectContent label={label} />}
/>
<Select
value={getParamValue("pagador")}
onValueChange={(value) =>
handleFilterChange(
"pagador",
value === FILTER_EMPTY_VALUE ? null : value
)
}
disabled={isPending}
>
<SelectTrigger
className="w-[150px] text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{selectedPagador ? (
<PagadorSelectContent
label={selectedPagador.label}
avatarUrl={selectedPagador.avatarUrl}
/>
) : (
"Pagador"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{pagadorSelectOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PagadorSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={categoriaOpen} onOpenChange={setCategoriaOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={categoriaOpen}
className="w-[150px] justify-between text-sm border-dashed border-input"
disabled={isPending}
>
<span className="truncate flex items-center gap-2">
{selectedCategoria ? (
<CategoriaSelectContent
label={selectedCategoria.label}
icon={selectedCategoria.icon}
/>
) : (
"Categoria"
)}
</span>
<ChevronsUpDownIcon 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("categoria", null);
setCategoriaOpen(false);
}}
>
Todas
{categoriaValue === FILTER_EMPTY_VALUE ? (
<CheckIcon className="ml-auto size-4" />
) : null}
</CommandItem>
{categoriaOptions.map((option) => (
<CommandItem
key={option.slug}
value={option.slug}
onSelect={() => {
handleFilterChange("categoria", option.slug);
setCategoriaOpen(false);
}}
>
<CategoriaSelectContent
label={option.label}
icon={option.icon}
/>
{categoriaValue === option.slug ? (
<CheckIcon className="ml-auto size-4" />
) : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Select
value={getParamValue("contaCartao")}
onValueChange={(value) =>
handleFilterChange(
"contaCartao",
value === FILTER_EMPTY_VALUE ? null : value
)
}
disabled={isPending}
>
<SelectTrigger
className="w-[150px] text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{selectedContaCartao ? (
<ContaCartaoSelectContent
label={selectedContaCartao.label}
logo={selectedContaCartao.logo}
isCartao={selectedContaCartao.kind === "cartao"}
/>
) : (
"Conta/Cartão"
)}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{contaOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Contas</SelectLabel>
{contaOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
{cartaoOptions.length > 0 ? (
<SelectGroup>
<SelectLabel>Cartões</SelectLabel>
{cartaoOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ContaCartaoSelectContent
label={option.label}
logo={option.logo}
isCartao={true}
/>
</SelectItem>
))}
</SelectGroup>
) : null}
</SelectContent>
</Select>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar"
aria-label="Buscar lançamentos"
className="w-[150px] text-sm border-dashed"
/>
<Button
type="button"
variant="link"
size="sm"
onClick={handleReset}
disabled={isPending}
>
Limpar
</Button>
</div>
);
}

View File

@@ -0,0 +1,857 @@
"use client";
import { EmptyState } from "@/components/empty-state";
import MoneyValues from "@/components/money-values";
import { TypeBadge } from "@/components/type-badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getAvatarSrc } from "@/lib/pagadores/utils";
import { formatDate } from "@/lib/utils/date";
import { getConditionIcon, getPaymentMethodIcon } from "@/lib/utils/icons";
import { cn } from "@/lib/utils/ui";
import { title_font } from "@/public/fonts/font_index";
import {
RiAddCircleFill,
RiAddCircleLine,
RiArrowLeftRightLine,
RiBankCard2Line,
RiBankLine,
RiChat1Line,
RiCheckLine,
RiDeleteBin5Line,
RiEyeLine,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
RiPencilLine,
RiThumbUpFill,
RiThumbUpLine,
RiTimeLine,
} from "@remixicon/react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
RowSelectionState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import type {
ContaCartaoFilterOption,
LancamentoFilterOption,
LancamentoItem,
} from "../types";
import { LancamentosFilters } from "./lancamentos-filters";
const resolveLogoSrc = (logo: string | null) => {
if (!logo) {
return null;
}
const fileName = logo.split("/").filter(Boolean).pop() ?? logo;
return `/logos/${fileName}`;
};
type BuildColumnsArgs = {
onEdit?: (item: LancamentoItem) => void;
onConfirmDelete?: (item: LancamentoItem) => void;
onViewDetails?: (item: LancamentoItem) => void;
onToggleSettlement?: (item: LancamentoItem) => void;
onAnticipate?: (item: LancamentoItem) => void;
onViewAnticipationHistory?: (item: LancamentoItem) => void;
isSettlementLoading: (id: string) => boolean;
showActions: boolean;
};
const buildColumns = ({
onEdit,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions,
}: BuildColumnsArgs): ColumnDef<LancamentoItem>[] => {
const noop = () => undefined;
const handleEdit = onEdit ?? noop;
const handleConfirmDelete = onConfirmDelete ?? noop;
const handleViewDetails = onViewDetails ?? noop;
const handleToggleSettlement = onToggleSettlement ?? noop;
const handleAnticipate = onAnticipate ?? noop;
const handleViewAnticipationHistory = onViewAnticipationHistory ?? noop;
const columns: ColumnDef<LancamentoItem>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Selecionar todos"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Selecionar linha"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "purchaseDate",
header: "Data",
cell: ({ row }) => (
<span className="whitespace-nowrap text-muted-foreground">
{formatDate(row.original.purchaseDate)}
</span>
),
},
{
accessorKey: "name",
header: "Estabelecimento",
cell: ({ row }) => {
const {
name,
installmentCount,
currentInstallment,
paymentMethod,
dueDate,
note,
isDivided,
isAnticipated,
} = row.original;
const installmentBadge =
currentInstallment && installmentCount
? `${currentInstallment} de ${installmentCount}`
: null;
const isBoleto = paymentMethod === "Boleto" && dueDate;
const dueDateLabel =
isBoleto && dueDate ? `Venc. ${formatDate(dueDate)}` : null;
const hasNote = Boolean(note?.trim().length);
const isLastInstallment =
currentInstallment === installmentCount &&
installmentCount &&
installmentCount > 1;
return (
<span className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="line-clamp-2 max-w-[180px] font-bold truncate">
{name}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{name}
</TooltipContent>
</Tooltip>
{isDivided && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiGroupLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Dividido entre pagadores</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
Dividido entre pagadores
</TooltipContent>
</Tooltip>
)}
{isLastInstallment ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Image
src="/icones/party.svg"
alt="Última parcela"
width={16}
height={16}
className="h-4 w-4"
/>
<span className="sr-only">Última parcela</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Última parcela!</TooltipContent>
</Tooltip>
) : null}
{installmentBadge ? (
<Badge variant="outline" className="px-2 text-xs">
{installmentBadge}
</Badge>
) : null}
{dueDateLabel ? (
<Badge variant="outline" className="px-2 text-xs">
{dueDateLabel}
</Badge>
) : null}
{isAnticipated && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1">
<RiTimeLine
size={14}
className="text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Parcela antecipada</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">Parcela antecipada</TooltipContent>
</Tooltip>
)}
{hasNote ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex rounded-full p-1 hover:bg-muted/60">
<RiChat1Line
className="h-4 w-4 text-muted-foreground"
aria-hidden
/>
<span className="sr-only">Ver anotação</span>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs whitespace-pre-line text-sm"
>
{note}
</TooltipContent>
</Tooltip>
) : null}
</span>
);
},
},
{
accessorKey: "transactionType",
header: "Transação",
cell: ({ row }) => (
<TypeBadge
type={
row.original.transactionType as
| "Despesa"
| "Receita"
| "Transferência"
}
/>
),
},
{
accessorKey: "amount",
header: "Valor",
cell: ({ row }) => {
const isReceita = row.original.transactionType === "Receita";
const isTransfer = row.original.transactionType === "Transferência";
return (
<MoneyValues
amount={row.original.amount}
className={cn(
"whitespace-nowrap",
isReceita
? "text-green-600 dark:text-green-400"
: "text-foreground",
isTransfer && "text-blue-700 dark:text-blue-500"
)}
/>
);
},
},
{
accessorKey: "condition",
header: "Condição",
cell: ({ row }) => {
const condition = row.original.condition;
const icon = getConditionIcon(condition);
return (
<span className="flex items-center gap-2">
{icon}
<span>{condition}</span>
</span>
);
},
},
{
accessorKey: "paymentMethod",
header: "Forma de Pagamento",
cell: ({ row }) => {
const method = row.original.paymentMethod;
const icon = getPaymentMethodIcon(method);
return (
<span className="flex items-center gap-2">
{icon}
<span>{method}</span>
</span>
);
},
},
{
accessorKey: "pagadorName",
header: "Pagador",
cell: ({ row }) => {
const { pagadorId, pagadorName, pagadorAvatar } = row.original;
if (!pagadorName) {
return <Badge variant="outline"></Badge>;
}
const label = pagadorName.trim() || "Sem pagador";
const displayName = label.split(/\s+/)[0] ?? label;
const avatarSrc = getAvatarSrc(pagadorAvatar);
const initial = displayName.charAt(0).toUpperCase() || "?";
const content = (
<>
<Avatar className="size-6 border border-border/60 bg-background">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-[10px] font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
<span className="truncate">{displayName}</span>
</>
);
if (!pagadorId) {
return (
<Badge
variant="outline"
className="max-w-[200px] px-2 py-0.5"
title={label}
>
<span className="inline-flex items-center gap-2">{content}</span>
</Badge>
);
}
return (
<Badge
asChild
variant="outline"
className="max-w-[200px] px-2 py-0.5"
>
<Link
href={`/pagadores/${pagadorId}`}
className="inline-flex items-center gap-2"
title={label}
>
{content}
</Link>
</Badge>
);
},
},
{
id: "contaCartao",
header: "Conta/Cartão",
cell: ({ row }) => {
const {
cartaoName,
contaName,
cartaoLogo,
contaLogo,
cartaoId,
contaId,
} = row.original;
const label = cartaoName ?? contaName;
const logoSrc = resolveLogoSrc(cartaoLogo ?? contaLogo);
const href = cartaoId
? `/cartoes/${cartaoId}/fatura`
: contaId
? `/contas/${contaId}/extrato`
: null;
const Icon = cartaoId ? RiBankCard2Line : contaId ? RiBankLine : null;
if (!label) {
return "—";
}
return (
<Link
href={href ?? "#"}
className={cn(
"flex items-center gap-2",
href ? "underline " : "pointer-events-none"
)}
aria-disabled={!href}
>
{logoSrc ? (
<Image
src={logoSrc}
alt={`Logo de ${label}`}
width={32}
height={32}
className="rounded-lg"
/>
) : null}
<span className="truncate">{label}</span>
{Icon ? (
<Icon className="size-4 text-muted-foreground" aria-hidden />
) : null}
</Link>
);
},
},
];
if (showActions) {
columns.push({
id: "actions",
header: "Ações",
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
{(() => {
const paymentMethod = row.original.paymentMethod;
const showSettlementButton = [
"Pix",
"Boleto",
"Cartão de crédito",
"Dinheiro",
"Cartão de débito",
].includes(paymentMethod);
if (!showSettlementButton) {
return null;
}
const canToggleSettlement =
paymentMethod === "Pix" ||
paymentMethod === "Boleto" ||
paymentMethod === "Dinheiro" ||
paymentMethod === "Cartão de débito";
const readOnly = row.original.readonly;
const loading = isSettlementLoading(row.original.id);
const settled = Boolean(row.original.isSettled);
const Icon = settled ? RiThumbUpFill : RiThumbUpLine;
return (
<Button
variant={settled ? "secondary" : "ghost"}
size="icon-sm"
onClick={() => handleToggleSettlement(row.original)}
disabled={loading || readOnly || !canToggleSettlement}
className={canToggleSettlement ? undefined : "opacity-70"}
>
{loading ? (
<Spinner className="size-4" />
) : (
<Icon className={cn("size-4", settled && "text-green-600")} />
)}
<span className="sr-only">
{settled ? "Desfazer pagamento" : "Marcar como pago"}
</span>
</Button>
);
})()}
<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)}
>
<RiEyeLine className="size-4" />
Detalhes
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleEdit(row.original)}
disabled={row.original.readonly}
>
<RiPencilLine className="size-4" />
Editar
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => handleConfirmDelete(row.original)}
disabled={row.original.readonly}
>
<RiDeleteBin5Line className="size-4" />
Remover
</DropdownMenuItem>
{/* Opções de Antecipação */}
{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-green-500" />
Parcela Antecipada
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
),
});
}
return columns;
};
type LancamentosTableProps = {
data: LancamentoItem[];
pagadorFilterOptions?: LancamentoFilterOption[];
categoriaFilterOptions?: LancamentoFilterOption[];
contaCartaoFilterOptions?: ContaCartaoFilterOption[];
onCreate?: () => void;
onMassAdd?: () => void;
onEdit?: (item: LancamentoItem) => void;
onConfirmDelete?: (item: LancamentoItem) => void;
onBulkDelete?: (items: LancamentoItem[]) => void;
onViewDetails?: (item: LancamentoItem) => void;
onToggleSettlement?: (item: LancamentoItem) => void;
onAnticipate?: (item: LancamentoItem) => void;
onViewAnticipationHistory?: (item: LancamentoItem) => void;
isSettlementLoading?: (id: string) => boolean;
showActions?: boolean;
showFilters?: boolean;
};
export function LancamentosTable({
data,
pagadorFilterOptions = [],
categoriaFilterOptions = [],
contaCartaoFilterOptions = [],
onCreate,
onMassAdd,
onEdit,
onConfirmDelete,
onBulkDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions = true,
showFilters = true,
}: LancamentosTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: "purchaseDate", desc: true },
]);
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 30,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const columns = useMemo(
() =>
buildColumns({
onEdit,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading: isSettlementLoading ?? (() => false),
showActions,
}),
[
onEdit,
onConfirmDelete,
onViewDetails,
onToggleSettlement,
onAnticipate,
onViewAnticipationHistory,
isSettlementLoading,
showActions,
]
);
const table = useReactTable({
data,
columns,
state: {
sorting,
pagination,
rowSelection,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
enableRowSelection: true,
});
const rowModel = table.getRowModel();
const hasRows = rowModel.rows.length > 0;
const totalRows = table.getCoreRowModel().rows.length;
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedCount = selectedRows.length;
const selectedTotal = selectedRows.reduce(
(total, row) => total + (row.original.amount ?? 0),
0
);
const handleBulkDelete = () => {
if (onBulkDelete && selectedCount > 0) {
const selectedItems = selectedRows.map((row) => row.original);
onBulkDelete(selectedItems);
setRowSelection({});
}
};
const showTopControls =
Boolean(onCreate) || Boolean(onMassAdd) || showFilters;
return (
<TooltipProvider>
{showTopControls ? (
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
{onCreate || onMassAdd ? (
<div className="flex gap-2">
{onCreate ? (
<Button onClick={onCreate} className="w-full sm:w-auto">
<RiAddCircleLine className="size-4" />
Novo lançamento
</Button>
) : null}
{onMassAdd ? (
<Button
onClick={onMassAdd}
variant="outline"
size="icon"
className="shrink-0"
>
<RiAddCircleFill className="size-4" />
<span className="sr-only">
Adicionar múltiplos lançamentos
</span>
</Button>
) : null}
</div>
) : (
<span className={showFilters ? "hidden sm:block" : ""} />
)}
{showFilters ? (
<LancamentosFilters
pagadorOptions={pagadorFilterOptions}
categoriaOptions={categoriaFilterOptions}
contaCartaoOptions={contaCartaoFilterOptions}
className="w-full lg:flex-1 lg:justify-end"
/>
) : null}
</div>
) : null}
{selectedCount > 0 && onBulkDelete ? (
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/50 px-4 py-2">
<div className="flex flex-col text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-2">
<span>
{selectedCount}{" "}
{selectedCount === 1 ? "item selecionado" : "itens selecionados"}
</span>
<span className="hidden sm:inline" aria-hidden>
</span>
<span>
Total:{" "}
<MoneyValues
amount={selectedTotal}
className="inline font-medium text-foreground"
/>
</span>
</div>
<Button
onClick={handleBulkDelete}
variant="destructive"
size="sm"
className="ml-auto"
>
<RiDeleteBin5Line className="size-4" />
Remover selecionados
</Button>
</div>
) : null}
<Card className="py-2">
<CardContent className="px-2 py-4 sm:px-4">
{hasRows ? (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader className={`${title_font.className}`}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="whitespace-nowrap"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rowModel.rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Exibindo {rowModel.rows.length} de {totalRows} lançamentos
</span>
<Select
value={pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="w-max">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5 linhas</SelectItem>
<SelectItem value="10">10 linhas</SelectItem>
<SelectItem value="20">20 linhas</SelectItem>
<SelectItem value="30">30 linhas</SelectItem>
<SelectItem value="40">40 linhas</SelectItem>
<SelectItem value="50">50 linhas</SelectItem>
<SelectItem value="100">100 linhas</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Anterior
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Próximo
</Button>
</div>
</div>
</>
) : (
<div className="flex w-full items-center justify-center py-12">
<EmptyState
media={<RiArrowLeftRightLine className="size-6 text-primary" />}
title="Nenhum lançamento encontrado"
description="Ajuste os filtros ou cadastre um novo lançamento para visualizar aqui."
/>
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
);
}