chore: remover páginas estabelecimentos e gastos-por-categoria

- Remove /estabelecimentos e todos seus componentes e actions
- Remove /relatorios/gastos-por-categoria e seus arquivos
- Remove tabela `estabelecimentos` do schema e migration 0019
- Remove nav items de ambas as features do sidebar
- Reverte widget expenses-by-category ao estado original
- Remove filtro de estabelecimento dos lançamentos (filters, table, page-helpers)
- Reverte getRecentEstablishmentsAction para query apenas em lancamentos
- Limpa CHANGELOG removendo entradas das features removidas
This commit is contained in:
Felipe Coutinho
2026-02-21 21:27:37 +00:00
parent 94f6b0a986
commit f640990912
20 changed files with 155 additions and 1023 deletions

View File

@@ -24,25 +24,15 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [1.6.1] - 2026-02-18 ## [1.6.1] - 2026-02-18
### Adicionado
- Aba "Estabelecimentos" no menu lateral (Gestão Financeira): listagem de estabelecimentos com quantidade de lançamentos, criação de novos, exclusão (apenas quando não há lançamentos vinculados) e link "Ver vinculados" para lançamentos filtrados pelo estabelecimento
- Tabela `estabelecimentos` (migration 0019) e sugestões de estabelecimento nos lançamentos passam a incluir nomes cadastrados nessa tabela
- Filtro "Estabelecimento" no drawer de Filtros da página de lançamentos; parâmetro `estabelecimento` na URL para filtrar por nome
### Alterado ### Alterado
- Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}" - Transferências entre contas: nome do estabelecimento passa a ser "Saída - Transf. entre contas" na saída e "Entrada - Transf. entre contas" na entrada e adicionando em anotação no formato "de {conta origem} -> {conta destino}"
- Gráfico de pizza (Gastos por categoria): cores das fatias alinhadas às cores dos ícones das categorias na lista (paleta `getCategoryColor`)
- ChartContainer (Recharts): renderização do gráfico apenas após montagem no cliente e uso de `minWidth`/`minHeight` no ResponsiveContainer para evitar aviso "width(-1) and height(-1)" no console - ChartContainer (Recharts): renderização do gráfico apenas após montagem no cliente e uso de `minWidth`/`minHeight` no ResponsiveContainer para evitar aviso "width(-1) and height(-1)" no console
## [1.6.0] - 2026-02-18 ## [1.6.0] - 2026-02-18
### Adicionado ### Adicionado
- Item "Gastos por categoria" no menu lateral (seção Análise), com link para `/relatorios/gastos-por-categoria`
- Gráfico de pizza moderno (estilo donut) na página Gastos por categoria: fatias com espaçamento, labels de percentual nas fatias maiores, legenda ao lado
- Fatias do gráfico e itens da legenda clicáveis — navegam para a página de detalhe da categoria no período selecionado
- Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone - Preferência "Anotações em coluna" em Ajustes > Extrato e lançamentos: quando ativa, a anotação dos lançamentos aparece em coluna na tabela; quando inativa, permanece no balão (tooltip) no ícone
- Preferência "Ordem das colunas" em Ajustes > Extrato e lançamentos: lista ordenável por arraste para definir a ordem das colunas na tabela do extrato e dos lançamentos (Estabelecimento, Transação, Valor, etc.); a linha inteira é arrastável - Preferência "Ordem das colunas" em Ajustes > Extrato e lançamentos: lista ordenável por arraste para definir a ordem das colunas na tabela do extrato e dos lançamentos (Estabelecimento, Transação, Valor, etc.); a linha inteira é arrastável
- Coluna `extrato_note_as_column` e `lancamentos_column_order` na tabela `preferencias_usuario` (migrations 0017 e 0018) - Coluna `extrato_note_as_column` e `lancamentos_column_order` na tabela `preferencias_usuario` (migrations 0017 e 0018)
@@ -50,7 +40,6 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
### Alterado ### Alterado
- Tooltip do gráfico de pizza em Gastos por categoria oculto no mobile (evita informação flutuante em telas pequenas)
- Header do dashboard fixo apenas no mobile (`fixed top-0` com `md:static`); conteúdo com `pt-12 md:pt-0` para não ficar sob o header - Header do dashboard fixo apenas no mobile (`fixed top-0` com `md:static`); conteúdo com `pt-12 md:pt-0` para não ficar sob o header
- Abas da página Ajustes (Preferências, Companion, etc.): no mobile, rolagem horizontal com seta indicando mais opções à direita; scrollbar oculta - Abas da página Ajustes (Preferências, Companion, etc.): no mobile, rolagem horizontal com seta indicando mais opções à direita; scrollbar oculta
- Botões "Novo orçamento" e "Copiar orçamentos do último mês": no mobile, rolagem horizontal (`h-8`, `text-xs`) - Botões "Novo orçamento" e "Copiar orçamentos do último mês": no mobile, rolagem horizontal (`h-8`, `text-xs`)

View File

@@ -1,102 +0,0 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { estabelecimentos, lancamentos } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
const createSchema = z.object({
name: z
.string({ message: "Informe o nome do estabelecimento." })
.trim()
.min(1, "Informe o nome do estabelecimento."),
});
const deleteSchema = z.object({
id: uuidSchema("Estabelecimento"),
});
export async function createEstabelecimentoAction(
input: z.infer<typeof createSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createSchema.parse(input);
await db.insert(estabelecimentos).values({
name: data.name,
userId: user.id,
});
revalidateForEntity("estabelecimentos");
return { success: true, message: "Estabelecimento criado com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteEstabelecimentoAction(
input: z.infer<typeof deleteSchema>,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteSchema.parse(input);
const row = await db.query.estabelecimentos.findFirst({
columns: { id: true, name: true },
where: and(
eq(estabelecimentos.id, data.id),
eq(estabelecimentos.userId, user.id),
),
});
if (!row) {
return {
success: false,
error: "Estabelecimento não encontrado.",
};
}
const [linked] = await db
.select({ id: lancamentos.id })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
eq(lancamentos.name, row.name),
),
)
.limit(1);
if (linked) {
return {
success: false,
error:
"Não é possível excluir: existem lançamentos vinculados a este estabelecimento. Remova ou altere os lançamentos primeiro.",
};
}
await db
.delete(estabelecimentos)
.where(
and(
eq(estabelecimentos.id, data.id),
eq(estabelecimentos.userId, user.id),
),
);
revalidateForEntity("estabelecimentos");
return { success: true, message: "Estabelecimento excluído com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,66 +0,0 @@
import { count, eq } from "drizzle-orm";
import { estabelecimentos, lancamentos } from "@/db/schema";
import { db } from "@/lib/db";
export type EstabelecimentoRow = {
name: string;
lancamentosCount: number;
estabelecimentoId: string | null;
};
export async function fetchEstabelecimentosForUser(
userId: string,
): Promise<EstabelecimentoRow[]> {
const [countsByName, estabelecimentosRows] = await Promise.all([
db
.select({
name: lancamentos.name,
count: count().as("count"),
})
.from(lancamentos)
.where(eq(lancamentos.userId, userId))
.groupBy(lancamentos.name),
db.query.estabelecimentos.findMany({
columns: { id: true, name: true },
where: eq(estabelecimentos.userId, userId),
}),
]);
const map = new Map<
string,
{ lancamentosCount: number; estabelecimentoId: string | null }
>();
for (const row of countsByName) {
const name = row.name?.trim();
if (name == null || name.length === 0) continue;
map.set(name, {
lancamentosCount: Number(row.count ?? 0),
estabelecimentoId: null,
});
}
for (const row of estabelecimentosRows) {
const name = row.name?.trim();
if (name == null || name.length === 0) continue;
const existing = map.get(name);
if (existing) {
existing.estabelecimentoId = row.id;
} else {
map.set(name, {
lancamentosCount: 0,
estabelecimentoId: row.id,
});
}
}
return Array.from(map.entries())
.map(([name, data]) => ({
name,
lancamentosCount: data.lancamentosCount,
estabelecimentoId: data.estabelecimentoId,
}))
.sort((a, b) =>
a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" }),
);
}

View File

@@ -1,23 +0,0 @@
import { RiStore2Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Estabelecimentos | OpenMonetis",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiStore2Line />}
title="Estabelecimentos"
subtitle="Gerencie os estabelecimentos dos seus lançamentos. Crie novos, exclua os que não têm lançamentos vinculados e abra o que está vinculado a cada um."
/>
{children}
</section>
);
}

View File

@@ -1,19 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<Skeleton className="h-10 w-[200px]" />
</div>
<div className="rounded-md border">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { EstabelecimentosPage } from "@/components/estabelecimentos/estabelecimentos-page";
import { getUserId } from "@/lib/auth/server";
import { fetchEstabelecimentosForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const rows = await fetchEstabelecimentosForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<EstabelecimentosPage rows={rows} />
</main>
);
}

View File

@@ -7,7 +7,6 @@ import {
cartoes, cartoes,
categorias, categorias,
contas, contas,
estabelecimentos,
lancamentos, lancamentos,
pagadores, pagadores,
} from "@/db/schema"; } from "@/db/schema";
@@ -1640,20 +1639,17 @@ export async function deleteMultipleLancamentosAction(
} }
} }
// Get unique establishment names: from estabelecimentos table + last 3 months from lancamentos // Get unique establishment names from the last 3 months
export async function getRecentEstablishmentsAction(): Promise<string[]> { export async function getRecentEstablishmentsAction(): Promise<string[]> {
try { try {
const user = await getUser(); const user = await getUser();
// Calculate date 3 months ago
const threeMonthsAgo = new Date(); const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const [estabelecimentosRows, lancamentosResults] = await Promise.all([ // Fetch establishment names from the last 3 months
db.query.estabelecimentos.findMany({ const results = await db
columns: { name: true },
where: eq(estabelecimentos.userId, user.id),
}),
db
.select({ name: lancamentos.name }) .select({ name: lancamentos.name })
.from(lancamentos) .from(lancamentos)
.where( .where(
@@ -1662,42 +1658,24 @@ export async function getRecentEstablishmentsAction(): Promise<string[]> {
gte(lancamentos.purchaseDate, threeMonthsAgo), gte(lancamentos.purchaseDate, threeMonthsAgo),
), ),
) )
.orderBy(desc(lancamentos.purchaseDate)), .orderBy(desc(lancamentos.purchaseDate));
]);
const fromTable = estabelecimentosRows // Remove duplicates and filter empty names
.map((r) => r.name) const uniqueNames = Array.from(
.filter( new Set(
(name): name is string => results
name != null && name.trim().length > 0,
);
const fromLancamentos = lancamentosResults
.map((r) => r.name) .map((r) => r.name)
.filter( .filter(
(name): name is string => (name): name is string =>
name != null && name != null &&
name.trim().length > 0 && name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"), !name.toLowerCase().startsWith("pagamento fatura"),
),
),
); );
const seen = new Set<string>(); // Return top 50 most recent unique establishments
const unique: string[] = []; return uniqueNames.slice(0, 100);
for (const name of fromTable) {
const key = name.trim();
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
}
for (const name of fromLancamentos) {
const key = name.trim();
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
}
return unique.slice(0, 100);
} catch (error) { } catch (error) {
console.error("Error fetching recent establishments:", error); console.error("Error fetching recent establishments:", error);
return []; return [];

View File

@@ -1,23 +0,0 @@
import { RiPieChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Gastos por categoria | OpenMonetis",
};
export default function GastosPorCategoriaLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPieChartLine />}
title="Gastos por categoria"
subtitle="Visualize suas despesas divididas por categoria no mês selecionado. Altere o mês para comparar períodos."
/>
{children}
</section>
);
}

View File

@@ -1,30 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function GastosPorCategoriaLoading() {
return (
<main className="flex flex-col gap-6">
<div className="h-14 animate-pulse rounded-xl bg-foreground/10" />
<div className="rounded-xl border p-4 md:p-6 space-y-4">
<div className="flex gap-2">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-20 rounded-lg" />
</div>
<div className="space-y-3 pt-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-dashed">
<div className="flex items-center gap-2">
<Skeleton className="size-10 rounded-lg" />
<div className="space-y-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -1,100 +0,0 @@
import {
RiArrowDownSFill,
RiArrowUpSFill,
RiPieChartLine,
} from "@remixicon/react";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import MoneyValues from "@/components/money-values";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getUserId } from "@/lib/auth/server";
import { fetchExpensesByCategory } from "@/lib/dashboard/categories/expenses-by-category";
import { calculatePercentageChange } from "@/lib/utils/math";
import { parsePeriodParam } from "@/lib/utils/period";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
) => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function GastosPorCategoriaPage({
searchParams,
}: PageProps) {
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const data = await fetchExpensesByCategory(userId, selectedPeriod);
const percentageChange = calculatePercentageChange(
data.currentTotal,
data.previousTotal,
);
const hasIncrease = percentageChange !== null && percentageChange > 0;
const hasDecrease = percentageChange !== null && percentageChange < 0;
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<RiPieChartLine className="size-4 text-primary" />
Resumo do mês
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<p className="text-xs text-muted-foreground">
Total de despesas no mês
</p>
<MoneyValues
amount={data.currentTotal}
className="text-2xl font-semibold"
/>
</div>
{percentageChange !== null && (
<span
className={`flex items-center gap-0.5 text-sm ${
hasIncrease
? "text-destructive"
: hasDecrease
? "text-success"
: "text-muted-foreground"
}`}
>
{hasIncrease && <RiArrowUpSFill className="size-4" />}
{hasDecrease && <RiArrowDownSFill className="size-4" />}
{percentageChange > 0 ? "+" : ""}
{percentageChange.toFixed(1)}% em relação ao mês anterior
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Mês anterior: <MoneyValues amount={data.previousTotal} />
</p>
</CardContent>
</Card>
<Card className="p-4 md:p-6">
<ExpensesByCategoryWidgetWithChart
data={data}
period={selectedPeriod}
/>
</Card>
</main>
);
}

View File

@@ -4,20 +4,20 @@ import {
RiArrowDownSFill, RiArrowDownSFill,
RiArrowUpSFill, RiArrowUpSFill,
RiExternalLinkLine, RiExternalLinkLine,
RiListUnordered,
RiPieChart2Line,
RiPieChartLine, RiPieChartLine,
RiWallet3Line, RiWallet3Line,
} from "@remixicon/react"; } from "@remixicon/react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useMemo, useState } from "react";
import { useMemo } from "react"; import { Pie, PieChart, Tooltip } from "recharts";
import { Cell, Pie, PieChart, Tooltip } from "recharts";
import { CategoryIconBadge } from "@/components/categorias/category-icon-badge"; import { CategoryIconBadge } from "@/components/categorias/category-icon-badge";
import { useIsMobile } from "@/hooks/use-mobile";
import MoneyValues from "@/components/money-values"; import MoneyValues from "@/components/money-values";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart"; import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category"; import type { ExpensesByCategoryData } from "@/lib/dashboard/categories/expenses-by-category";
import { getCategoryColor } from "@/lib/utils/category-colors";
import { formatPeriodForUrl } from "@/lib/utils/period"; import { formatPeriodForUrl } from "@/lib/utils/period";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { WidgetEmptyState } from "../widget-empty-state"; import { WidgetEmptyState } from "../widget-empty-state";
type ExpensesByCategoryWidgetWithChartProps = { type ExpensesByCategoryWidgetWithChartProps = {
@@ -35,32 +35,31 @@ const formatCurrency = (value: number) =>
currency: "BRL", currency: "BRL",
}).format(value); }).format(value);
type ChartDataItem = {
category: string;
name: string;
value: number;
percentage: number;
fill: string | undefined;
href: string | undefined;
};
export function ExpensesByCategoryWidgetWithChart({ export function ExpensesByCategoryWidgetWithChart({
data, data,
period, period,
}: ExpensesByCategoryWidgetWithChartProps) { }: ExpensesByCategoryWidgetWithChartProps) {
const router = useRouter(); const [activeTab, setActiveTab] = useState<"list" | "chart">("list");
const isMobile = useIsMobile();
const periodParam = formatPeriodForUrl(period); const periodParam = formatPeriodForUrl(period);
// Configuração do chart com as mesmas cores dos ícones das categorias (getCategoryColor) // Configuração do chart com cores do CSS
const chartConfig = useMemo(() => { const chartConfig = useMemo(() => {
const config: ChartConfig = {}; const config: ChartConfig = {};
const colors = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
"var(--chart-1)",
"var(--chart-2)",
];
if (data.categories.length <= 7) { if (data.categories.length <= 7) {
data.categories.forEach((category, index) => { data.categories.forEach((category, index) => {
config[category.categoryId] = { config[category.categoryId] = {
label: category.categoryName, label: category.categoryName,
color: getCategoryColor(index), color: colors[index % colors.length],
}; };
}); });
} else { } else {
@@ -69,80 +68,62 @@ export function ExpensesByCategoryWidgetWithChart({
top7.forEach((category, index) => { top7.forEach((category, index) => {
config[category.categoryId] = { config[category.categoryId] = {
label: category.categoryName, label: category.categoryName,
color: getCategoryColor(index), color: colors[index % colors.length],
}; };
}); });
config.outros = { config.outros = {
label: "Outros", label: "Outros",
color: getCategoryColor(7), color: "var(--chart-6)",
}; };
} }
return config; return config;
}, [data.categories]); }, [data.categories]);
// Preparar dados para o gráfico de pizza - Top 7 + Outros (com href para navegação) // Preparar dados para o gráfico de pizza - Top 7 + Outros
const chartData = useMemo((): ChartDataItem[] => { const chartData = useMemo(() => {
const buildItem = (
categoryId: string,
name: string,
value: number,
percentage: number,
fill: string | undefined,
): ChartDataItem => ({
category: categoryId,
name,
value,
percentage,
fill,
href:
categoryId === "outros"
? undefined
: `/categorias/${categoryId}?periodo=${periodParam}`,
});
if (data.categories.length <= 7) { if (data.categories.length <= 7) {
return data.categories.map((category) => return data.categories.map((category) => ({
buildItem( category: category.categoryId,
category.categoryId, name: category.categoryName,
category.categoryName, value: category.currentAmount,
category.currentAmount, percentage: category.percentageOfTotal,
category.percentageOfTotal, fill: chartConfig[category.categoryId]?.color,
chartConfig[category.categoryId]?.color, }));
),
);
} }
// Pegar top 7 categorias
const top7 = data.categories.slice(0, 7); const top7 = data.categories.slice(0, 7);
const others = data.categories.slice(7); const others = data.categories.slice(7);
// Somar o restante
const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0); const othersTotal = others.reduce((sum, cat) => sum + cat.currentAmount, 0);
const othersPercentage = others.reduce( const othersPercentage = others.reduce(
(sum, cat) => sum + cat.percentageOfTotal, (sum, cat) => sum + cat.percentageOfTotal,
0, 0,
); );
const top7Data = top7.map((category) => const top7Data = top7.map((category) => ({
buildItem( category: category.categoryId,
category.categoryId, name: category.categoryName,
category.categoryName, value: category.currentAmount,
category.currentAmount, percentage: category.percentageOfTotal,
category.percentageOfTotal, fill: chartConfig[category.categoryId]?.color,
chartConfig[category.categoryId]?.color, }));
),
); // Adicionar "Outros" se houver
if (others.length > 0) { if (others.length > 0) {
top7Data.push( top7Data.push({
buildItem( category: "outros",
"outros", name: "Outros",
"Outros", value: othersTotal,
othersTotal, percentage: othersPercentage,
othersPercentage, fill: chartConfig.outros?.color,
chartConfig.outros?.color, });
),
);
} }
return top7Data; return top7Data;
}, [data.categories, chartConfig, periodParam]); }, [data.categories, chartConfig]);
if (data.categories.length === 0) { if (data.categories.length === 0) {
return ( return (
@@ -155,146 +136,25 @@ export function ExpensesByCategoryWidgetWithChart({
} }
return ( return (
<div className="flex flex-col gap-8"> <Tabs
{/* Gráfico de pizza (donut) — fatias clicáveis */} value={activeTab}
<div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8"> onValueChange={(v) => setActiveTab(v as "list" | "chart")}
<ChartContainer className="w-full"
config={chartConfig}
className="h-[280px] w-full min-w-0 sm:h-[320px] sm:max-w-[360px]"
> >
<PieChart margin={{ top: 8, right: 8, bottom: 8, left: 8 }}> <div className="flex items-center justify-between">
<Pie <TabsList className="grid grid-cols-2">
data={chartData} <TabsTrigger value="list" className="text-xs">
cx="50%" <RiListUnordered className="size-3.5 mr-1" />
cy="50%" Lista
innerRadius="58%" </TabsTrigger>
outerRadius="92%" <TabsTrigger value="chart" className="text-xs">
paddingAngle={2} <RiPieChart2Line className="size-3.5 mr-1" />
dataKey="value" Gráfico
nameKey="category" </TabsTrigger>
stroke="transparent" </TabsList>
onClick={(payload: ChartDataItem) => {
if (payload?.href) router.push(payload.href);
}}
label={(props: {
cx?: number;
cy?: number;
midAngle?: number;
innerRadius?: number;
outerRadius?: number;
percent?: number;
}) => {
const { cx = 0, cy = 0, midAngle = 0, innerRadius = 0, outerRadius = 0, percent = 0 } = props;
const percentage = percent * 100;
if (percentage < 6) return null;
const radius = (Number(innerRadius) + Number(outerRadius)) / 2;
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
return (
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-foreground text-[10px] font-medium"
>
{formatPercentage(percentage)}
</text>
);
}}
labelLine={false}
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.fill}
className={
entry.href
? "cursor-pointer transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
: ""
}
style={
entry.href
? { filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.08))" }
: undefined
}
/>
))}
</Pie>
{!isMobile && (
<Tooltip
content={({ active, payload }) => {
if (active && payload?.length) {
const d = payload[0].payload as ChartDataItem;
return (
<div className="rounded-xl border border-border/80 bg-card px-3 py-2.5 shadow-lg">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-foreground">
{d.name}
</span>
<span className="text-sm font-semibold tabular-nums">
{formatCurrency(d.value)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatPercentage(d.percentage)} do total
</span>
{d.href && (
<span className="mt-1 text-[10px] text-primary">
Clique para ver detalhes
</span>
)}
</div>
</div>
);
}
return null;
}}
cursor={false}
/>
)}
</PieChart>
</ChartContainer>
{/* Legenda clicável */}
<div className="flex flex-wrap gap-x-4 gap-y-2 sm:flex-1 sm:flex-col sm:gap-1.5">
{chartData.map((entry, index) => {
const content = (
<>
<span
className="size-3 shrink-0 rounded-full ring-1 ring-border/50"
style={{ backgroundColor: entry.fill }}
aria-hidden
/>
<span className="truncate text-sm text-muted-foreground">
{entry.name}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground/80">
{formatPercentage(entry.percentage)}
</span>
</>
);
return entry.href ? (
<Link
key={`legend-${index}`}
href={entry.href}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{content}
</Link>
) : (
<div
key={`legend-${index}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5"
>
{content}
</div>
);
})}
</div>
</div> </div>
{/* Lista de categorias */} <TabsContent value="list" className="mt-0">
<div className="border-t border-dashed pt-6">
<div className="flex flex-col px-0"> <div className="flex flex-col px-0">
{data.categories.map((category, index) => { {data.categories.map((category, index) => {
const hasIncrease = const hasIncrease =
@@ -404,7 +264,65 @@ export function ExpensesByCategoryWidgetWithChart({
); );
})} })}
</div> </div>
</TabsContent>
<TabsContent value="chart" className="mt-0">
<div className="flex items-center gap-4">
<ChartContainer config={chartConfig} className="h-[280px] flex-1">
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry) => formatPercentage(entry.percentage)}
outerRadius={75}
dataKey="value"
nameKey="category"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">
{data.name}
</span>
<span className="font-bold text-foreground">
{formatCurrency(data.value)}
</span>
<span className="text-xs text-muted-foreground">
{formatPercentage(data.percentage)} do total
</span>
</div>
</div> </div>
</div> </div>
); );
} }
return null;
}}
/>
</PieChart>
</ChartContainer>
<div className="flex flex-col gap-2 min-w-[140px]">
{chartData.map((entry, index) => (
<div key={`legend-${index}`} className="flex items-center gap-2">
<div
className="size-3 rounded-sm shrink-0"
style={{ backgroundColor: entry.fill }}
/>
<span className="text-xs text-muted-foreground truncate">
{entry.name}
</span>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,97 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { createEstabelecimentoAction } from "@/app/(dashboard)/estabelecimentos/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RiAddCircleLine } from "@remixicon/react";
interface EstabelecimentoCreateDialogProps {
trigger?: React.ReactNode;
}
export function EstabelecimentoCreateDialog({
trigger,
}: EstabelecimentoCreateDialogProps) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [isPending, startTransition] = useTransition();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
startTransition(async () => {
const result = await createEstabelecimentoAction({ name: trimmed });
if (result.success) {
toast.success(result.message);
setName("");
setOpen(false);
} else {
toast.error(result.error);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button>
<RiAddCircleLine className="size-4" />
Novo estabelecimento
</Button>
)}
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Novo estabelecimento</DialogTitle>
<DialogDescription>
Adicione um nome para usar nos lançamentos. Ele aparecerá na lista
e nas sugestões ao criar ou editar lançamentos.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="estabelecimento-name">Nome</Label>
<Input
id="estabelecimento-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Supermercado, Posto, Farmácia"
disabled={isPending}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending || !name.trim()}>
{isPending ? "Salvando…" : "Criar"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,154 +0,0 @@
"use client";
import { RiDeleteBin5Line, RiExternalLinkLine } from "@remixicon/react";
import Link from "next/link";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { deleteEstabelecimentoAction } from "@/app/(dashboard)/estabelecimentos/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { EstabelecimentoLogo } from "@/components/lancamentos/shared/estabelecimento-logo";
import { EstabelecimentoCreateDialog } from "./estabelecimento-create-dialog";
import type { EstabelecimentoRow } from "@/app/(dashboard)/estabelecimentos/data";
interface EstabelecimentosPageProps {
rows: EstabelecimentoRow[];
}
function buildLancamentosUrl(name: string): string {
const params = new URLSearchParams();
params.set("estabelecimento", name);
return `/lancamentos?${params.toString()}`;
}
export function EstabelecimentosPage({ rows }: EstabelecimentosPageProps) {
const [deleteOpen, setDeleteOpen] = useState(false);
const [rowToDelete, setRowToDelete] = useState<EstabelecimentoRow | null>(
null,
);
const handleDeleteRequest = useCallback((row: EstabelecimentoRow) => {
setRowToDelete(row);
setDeleteOpen(true);
}, []);
const handleDeleteOpenChange = useCallback((open: boolean) => {
setDeleteOpen(open);
if (!open) setRowToDelete(null);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!rowToDelete?.estabelecimentoId) return;
const result = await deleteEstabelecimentoAction({
id: rowToDelete.estabelecimentoId,
});
if (result.success) {
toast.success(result.message);
setDeleteOpen(false);
setRowToDelete(null);
return;
}
toast.error(result.error);
throw new Error(result.error);
}, [rowToDelete]);
const canDelete = (row: EstabelecimentoRow) =>
row.lancamentosCount === 0 && row.estabelecimentoId != null;
return (
<>
<div className="flex w-full flex-col gap-6">
<div className="flex justify-start">
<EstabelecimentoCreateDialog />
</div>
{rows.length === 0 ? (
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed bg-muted/10 p-10 text-center text-sm text-muted-foreground">
Nenhum estabelecimento ainda. Crie um ou use a lista que será
preenchida conforme você adiciona lançamentos.
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Estabelecimento</TableHead>
<TableHead className="text-right">Lançamentos</TableHead>
<TableHead className="w-[180px] text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={`${row.name}-${row.estabelecimentoId ?? "x"}`}>
<TableCell>
<div className="flex items-center gap-3">
<EstabelecimentoLogo name={row.name} size={32} />
<span className="font-medium">{row.name}</span>
</div>
</TableCell>
<TableCell className="text-right">
{row.lancamentosCount}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link
href={buildLancamentosUrl(row.name)}
className="inline-flex items-center gap-1"
>
<RiExternalLinkLine className="size-4" />
Ver vinculados
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={!canDelete(row)}
onClick={() => handleDeleteRequest(row)}
title={
row.lancamentosCount > 0
? "Não é possível excluir: há lançamentos vinculados."
: "Excluir estabelecimento"
}
>
<RiDeleteBin5Line className="size-4" />
Excluir
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<ConfirmActionDialog
open={deleteOpen}
onOpenChange={handleDeleteOpenChange}
title="Excluir estabelecimento?"
description={
rowToDelete
? `Tem certeza que deseja excluir "${rowToDelete.name}"? Esta ação não pode ser desfeita.`
: ""
}
confirmLabel="Excluir"
variant="destructive"
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -386,7 +386,6 @@ export function LancamentosPage({
pagadorFilterOptions={pagadorFilterOptions} pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions} categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions} contaCartaoFilterOptions={contaCartaoFilterOptions}
estabelecimentosOptions={estabelecimentos}
selectedPeriod={selectedPeriod} selectedPeriod={selectedPeriod}
onCreate={allowCreate ? handleCreate : undefined} onCreate={allowCreate ? handleCreate : undefined}
onMassAdd={allowCreate ? handleMassAdd : undefined} onMassAdd={allowCreate ? handleMassAdd : undefined}

View File

@@ -122,7 +122,6 @@ interface LancamentosFiltersProps {
pagadorOptions: LancamentoFilterOption[]; pagadorOptions: LancamentoFilterOption[];
categoriaOptions: LancamentoFilterOption[]; categoriaOptions: LancamentoFilterOption[];
contaCartaoOptions: ContaCartaoFilterOption[]; contaCartaoOptions: ContaCartaoFilterOption[];
estabelecimentosOptions?: string[];
className?: string; className?: string;
exportButton?: ReactNode; exportButton?: ReactNode;
hideAdvancedFilters?: boolean; hideAdvancedFilters?: boolean;
@@ -132,7 +131,6 @@ export function LancamentosFilters({
pagadorOptions, pagadorOptions,
categoriaOptions, categoriaOptions,
contaCartaoOptions, contaCartaoOptions,
estabelecimentosOptions = [],
className, className,
exportButton, exportButton,
hideAdvancedFilters = false, hideAdvancedFilters = false,
@@ -237,16 +235,6 @@ export function LancamentosFilters({
? contaCartaoOptions.find((option) => option.slug === contaCartaoValue) ? contaCartaoOptions.find((option) => option.slug === contaCartaoValue)
: null; : null;
const estabelecimentoParam = searchParams.get("estabelecimento");
const estabelecimentoOptionsForSelect = [
...(estabelecimentoParam &&
estabelecimentoParam.trim() &&
!estabelecimentosOptions.includes(estabelecimentoParam.trim())
? [estabelecimentoParam.trim()]
: []),
...estabelecimentosOptions,
];
const [categoriaOpen, setCategoriaOpen] = useState(false); const [categoriaOpen, setCategoriaOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -256,8 +244,7 @@ export function LancamentosFilters({
searchParams.get("pagamento") || searchParams.get("pagamento") ||
searchParams.get("pagador") || searchParams.get("pagador") ||
searchParams.get("categoria") || searchParams.get("categoria") ||
searchParams.get("contaCartao") || searchParams.get("contaCartao");
searchParams.get("estabelecimento");
const handleResetFilters = () => { const handleResetFilters = () => {
handleReset(); handleReset();
@@ -531,45 +518,6 @@ export function LancamentosFilters({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{estabelecimentoOptionsForSelect.length > 0 ||
estabelecimentoParam?.trim() ? (
<div className="space-y-2">
<label className="text-sm font-medium">Estabelecimento</label>
<Select
value={
getParamValue("estabelecimento") || FILTER_EMPTY_VALUE
}
onValueChange={(value) =>
handleFilterChange(
"estabelecimento",
value === FILTER_EMPTY_VALUE ? null : value,
)
}
disabled={isPending}
>
<SelectTrigger
className="w-full text-sm border-dashed"
disabled={isPending}
>
<span className="truncate">
{getParamValue("estabelecimento") !== FILTER_EMPTY_VALUE &&
searchParams.get("estabelecimento")
? searchParams.get("estabelecimento")
: "Todos"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_EMPTY_VALUE}>Todos</SelectItem>
{estabelecimentoOptionsForSelect.map((name) => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div> </div>
<DrawerFooter> <DrawerFooter>

View File

@@ -715,7 +715,6 @@ type LancamentosTableProps = {
pagadorFilterOptions?: LancamentoFilterOption[]; pagadorFilterOptions?: LancamentoFilterOption[];
categoriaFilterOptions?: LancamentoFilterOption[]; categoriaFilterOptions?: LancamentoFilterOption[];
contaCartaoFilterOptions?: ContaCartaoFilterOption[]; contaCartaoFilterOptions?: ContaCartaoFilterOption[];
estabelecimentosOptions?: string[];
selectedPeriod?: string; selectedPeriod?: string;
onCreate?: (type: "Despesa" | "Receita") => void; onCreate?: (type: "Despesa" | "Receita") => void;
onMassAdd?: () => void; onMassAdd?: () => void;
@@ -742,7 +741,6 @@ export function LancamentosTable({
pagadorFilterOptions = [], pagadorFilterOptions = [],
categoriaFilterOptions = [], categoriaFilterOptions = [],
contaCartaoFilterOptions = [], contaCartaoFilterOptions = [],
estabelecimentosOptions = [],
selectedPeriod, selectedPeriod,
onCreate, onCreate,
onMassAdd, onMassAdd,
@@ -923,7 +921,6 @@ export function LancamentosTable({
pagadorOptions={pagadorFilterOptions} pagadorOptions={pagadorFilterOptions}
categoriaOptions={categoriaFilterOptions} categoriaOptions={categoriaFilterOptions}
contaCartaoOptions={contaCartaoFilterOptions} contaCartaoOptions={contaCartaoFilterOptions}
estabelecimentosOptions={estabelecimentosOptions}
className="w-full lg:flex-1 lg:justify-end" className="w-full lg:flex-1 lg:justify-end"
hideAdvancedFilters={hasOtherUserData} hideAdvancedFilters={hasOtherUserData}
exportButton={ exportButton={

View File

@@ -7,13 +7,11 @@ import {
RiDashboardLine, RiDashboardLine,
RiFileChartLine, RiFileChartLine,
RiFundsLine, RiFundsLine,
RiPieChartLine,
RiGroupLine, RiGroupLine,
RiInboxLine, RiInboxLine,
RiPriceTag3Line, RiPriceTag3Line,
RiSettings2Line, RiSettings2Line,
RiSparklingLine, RiSparklingLine,
RiStore2Line,
RiTodoLine, RiTodoLine,
} from "@remixicon/react"; } from "@remixicon/react";
@@ -126,11 +124,6 @@ export function createSidebarNavData(
url: "/orcamentos", url: "/orcamentos",
icon: RiFundsLine, icon: RiFundsLine,
}, },
{
title: "Estabelecimentos",
url: "/estabelecimentos",
icon: RiStore2Line,
},
], ],
}, },
{ {
@@ -167,11 +160,6 @@ export function createSidebarNavData(
url: "/relatorios/tendencias", url: "/relatorios/tendencias",
icon: RiFileChartLine, icon: RiFileChartLine,
}, },
{
title: "Gastos por categoria",
url: "/relatorios/gastos-por-categoria",
icon: RiPieChartLine,
},
{ {
title: "Uso de Cartões", title: "Uso de Cartões",
url: "/relatorios/uso-cartoes", url: "/relatorios/uso-cartoes",

View File

@@ -190,30 +190,6 @@ export const categorias = pgTable(
}), }),
); );
export const estabelecimentos = pgTable(
"estabelecimentos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("nome").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", {
mode: "date",
withTimezone: true,
})
.notNull()
.defaultNow(),
},
(table) => ({
userIdIdx: index("estabelecimentos_user_id_idx").on(table.userId),
userIdNameUnique: uniqueIndex("estabelecimentos_user_id_nome_key").on(
table.userId,
table.name,
),
}),
);
export const pagadores = pgTable( export const pagadores = pgTable(
"pagadores", "pagadores",
{ {
@@ -659,7 +635,6 @@ export const userRelations = relations(user, ({ many, one }) => ({
cartoes: many(cartoes), cartoes: many(cartoes),
categorias: many(categorias), categorias: many(categorias),
contas: many(contas), contas: many(contas),
estabelecimentos: many(estabelecimentos),
faturas: many(faturas), faturas: many(faturas),
lancamentos: many(lancamentos), lancamentos: many(lancamentos),
orcamentos: many(orcamentos), orcamentos: many(orcamentos),
@@ -701,16 +676,6 @@ export const categoriasRelations = relations(categorias, ({ one, many }) => ({
orcamentos: many(orcamentos), orcamentos: many(orcamentos),
})); }));
export const estabelecimentosRelations = relations(
estabelecimentos,
({ one }) => ({
user: one(user, {
fields: [estabelecimentos.userId],
references: [user.id],
}),
}),
);
export const pagadoresRelations = relations(pagadores, ({ one, many }) => ({ export const pagadoresRelations = relations(pagadores, ({ one, many }) => ({
user: one(user, { user: one(user, {
fields: [pagadores.userId], fields: [pagadores.userId],

View File

@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS "estabelecimentos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"nome" text NOT NULL,
"user_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "estabelecimentos_user_id_idx" ON "estabelecimentos" ("user_id");
CREATE UNIQUE INDEX IF NOT EXISTS "estabelecimentos_user_id_nome_key" ON "estabelecimentos" ("user_id", "nome");
DO $$ BEGIN
ALTER TABLE "estabelecimentos" ADD CONSTRAINT "estabelecimentos_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -37,7 +37,6 @@ export type LancamentoSearchFilters = {
categoriaFilter: string | null; categoriaFilter: string | null;
contaCartaoFilter: string | null; contaCartaoFilter: string | null;
searchFilter: string | null; searchFilter: string | null;
estabelecimentoFilter: string | null;
}; };
type BaseSluggedOption = { type BaseSluggedOption = {
@@ -123,7 +122,6 @@ export const extractLancamentoSearchFilters = (
categoriaFilter: getSingleParam(params, "categoria"), categoriaFilter: getSingleParam(params, "categoria"),
contaCartaoFilter: getSingleParam(params, "contaCartao"), contaCartaoFilter: getSingleParam(params, "contaCartao"),
searchFilter: getSingleParam(params, "q"), searchFilter: getSingleParam(params, "q"),
estabelecimentoFilter: getSingleParam(params, "estabelecimento"),
}); });
const normalizeLabel = (value: string | null | undefined) => const normalizeLabel = (value: string | null | undefined) =>
@@ -370,10 +368,6 @@ export const buildLancamentoWhere = ({
} }
} }
if (filters.estabelecimentoFilter?.trim()) {
where.push(eq(lancamentos.name, filters.estabelecimentoFilter.trim()));
}
const searchPattern = buildSearchPattern(filters.searchFilter); const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) { if (searchPattern) {
where.push( where.push(