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

@@ -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,
categorias,
contas,
estabelecimentos,
lancamentos,
pagadores,
} from "@/db/schema";
@@ -1640,64 +1639,43 @@ 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[]> {
try {
const user = await getUser();
// Calculate date 3 months ago
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const [estabelecimentosRows, lancamentosResults] = await Promise.all([
db.query.estabelecimentos.findMany({
columns: { name: true },
where: eq(estabelecimentos.userId, user.id),
}),
db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
gte(lancamentos.purchaseDate, threeMonthsAgo),
// Fetch establishment names from the last 3 months
const results = await db
.select({ name: lancamentos.name })
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, user.id),
gte(lancamentos.purchaseDate, threeMonthsAgo),
),
)
.orderBy(desc(lancamentos.purchaseDate));
// Remove duplicates and filter empty names
const uniqueNames = Array.from(
new Set(
results
.map((r) => r.name)
.filter(
(name): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),
),
)
.orderBy(desc(lancamentos.purchaseDate)),
]);
),
);
const fromTable = estabelecimentosRows
.map((r) => r.name)
.filter(
(name): name is string =>
name != null && name.trim().length > 0,
);
const fromLancamentos = lancamentosResults
.map((r) => r.name)
.filter(
(name): name is string =>
name != null &&
name.trim().length > 0 &&
!name.toLowerCase().startsWith("pagamento fatura"),
);
const seen = new Set<string>();
const unique: string[] = [];
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);
// Return top 50 most recent unique establishments
return uniqueNames.slice(0, 100);
} catch (error) {
console.error("Error fetching recent establishments:", error);
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>
);
}