refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,99 +1,98 @@
import { notFound } from "next/navigation";
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
import MonthNavigation from "@/components/month-picker/month-navigation";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
import {
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
buildOptionSets,
buildSluggedFilters,
fetchLancamentoFilterSources,
} from "@/lib/lancamentos/page-helpers";
import { displayPeriod, parsePeriodParam } from "@/lib/utils/period";
import { notFound } from "next/navigation";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
params: Promise<{ categoryId: string }>;
searchParams?: PageSearchParams;
params: Promise<{ categoryId: string }>;
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string
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;
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ params, searchParams }: PageProps) {
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const { categoryId } = await params;
const userId = await getUserId();
const resolvedSearchParams = searchParams ? await searchParams : undefined;
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
const [detail, filterSources, estabelecimentos] =
await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
]);
const [detail, filterSources, estabelecimentos] = await Promise.all([
fetchCategoryDetails(userId, categoryId, selectedPeriod),
fetchLancamentoFilterSources(userId),
getRecentEstablishmentsAction(),
]);
if (!detail) {
notFound();
}
if (!detail) {
notFound();
}
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const sluggedFilters = buildSluggedFilters(filterSources);
const {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
} = buildOptionSets({
...sluggedFilters,
pagadorRows: filterSources.pagadorRows,
});
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
const currentPeriodLabel = displayPeriod(detail.period);
const previousPeriodLabel = displayPeriod(detail.previousPeriod);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<CategoryDetailHeader
category={detail.category}
currentPeriodLabel={currentPeriodLabel}
previousPeriodLabel={previousPeriodLabel}
currentTotal={detail.currentTotal}
previousTotal={detail.previousTotal}
percentageChange={detail.percentageChange}
transactionCount={detail.transactions.length}
/>
<LancamentosPage
currentUserId={userId}
lancamentos={detail.transactions}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
/>
</main>
);
return (
<main className="flex flex-col gap-6">
<MonthNavigation />
<CategoryDetailHeader
category={detail.category}
currentPeriodLabel={currentPeriodLabel}
previousPeriodLabel={previousPeriodLabel}
currentTotal={detail.currentTotal}
previousTotal={detail.previousTotal}
percentageChange={detail.percentageChange}
transactionCount={detail.transactions.length}
/>
<LancamentosPage
currentUserId={userId}
lancamentos={detail.transactions}
pagadorOptions={pagadorOptions}
splitPagadorOptions={splitPagadorOptions}
defaultPagadorId={defaultPagadorId}
contaOptions={contaOptions}
cartaoOptions={cartaoOptions}
categoriaOptions={categoriaOptions}
pagadorFilterOptions={pagadorFilterOptions}
categoriaFilterOptions={categoriaFilterOptions}
contaCartaoFilterOptions={contaCartaoFilterOptions}
selectedPeriod={detail.period}
estabelecimentos={estabelecimentos}
allowCreate={true}
/>
</main>
);
}

View File

@@ -1,41 +1,41 @@
"use server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { categorias } from "@/db/schema";
import {
type ActionResult,
handleActionError,
revalidateForEntity,
type ActionResult,
handleActionError,
revalidateForEntity,
} from "@/lib/actions/helpers";
import { getUser } from "@/lib/auth/server";
import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { db } from "@/lib/db";
import { uuidSchema } from "@/lib/schemas/common";
import { normalizeIconInput } from "@/lib/utils/string";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const categoryBaseSchema = z.object({
name: z
.string({ message: "Informe o nome da categoria." })
.trim()
.min(1, "Informe o nome da categoria."),
type: z.enum(CATEGORY_TYPES, {
message: "Tipo de categoria inválido.",
}),
icon: z
.string()
.trim()
.max(100, "O ícone deve ter no máximo 100 caracteres.")
.nullish()
.transform((value) => normalizeIconInput(value)),
name: z
.string({ message: "Informe o nome da categoria." })
.trim()
.min(1, "Informe o nome da categoria."),
type: z.enum(CATEGORY_TYPES, {
message: "Tipo de categoria inválido.",
}),
icon: z
.string()
.trim()
.max(100, "O ícone deve ter no máximo 100 caracteres.")
.nullish()
.transform((value) => normalizeIconInput(value)),
});
const createCategorySchema = categoryBaseSchema;
const updateCategorySchema = categoryBaseSchema.extend({
id: uuidSchema("Categoria"),
id: uuidSchema("Categoria"),
});
const deleteCategorySchema = z.object({
id: uuidSchema("Categoria"),
id: uuidSchema("Categoria"),
});
type CategoryCreateInput = z.infer<typeof createCategorySchema>;
@@ -43,134 +43,134 @@ type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
export async function createCategoryAction(
input: CategoryCreateInput
input: CategoryCreateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createCategorySchema.parse(input);
try {
const user = await getUser();
const data = createCategorySchema.parse(input);
await db.insert(categorias).values({
name: data.name,
type: data.type,
icon: data.icon,
userId: user.id,
});
await db.insert(categorias).values({
name: data.name,
type: data.type,
icon: data.icon,
userId: user.id,
});
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateCategoryAction(
input: CategoryUpdateInput
input: CategoryUpdateInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateCategorySchema.parse(input);
try {
const user = await getUser();
const data = updateCategorySchema.parse(input);
// Buscar categoria antes de atualizar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
// Buscar categoria antes de atualizar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear edição das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
};
}
// Bloquear edição das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`,
};
}
const [updated] = await db
.update(categorias)
.set({
name: data.name,
type: data.type,
icon: data.icon,
})
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning();
const [updated] = await db
.update(categorias)
.set({
name: data.name,
type: data.type,
icon: data.icon,
})
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning();
if (!updated) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!updated) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteCategoryAction(
input: CategoryDeleteInput
input: CategoryDeleteInput,
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteCategorySchema.parse(input);
try {
const user = await getUser();
const data = deleteCategorySchema.parse(input);
// Buscar categoria antes de deletar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
// Buscar categoria antes de deletar para verificar restrições
const categoria = await db.query.categorias.findFirst({
columns: { id: true, name: true },
where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)),
});
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!categoria) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
// Bloquear remoção das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
};
}
// Bloquear remoção das categorias protegidas
const categoriasProtegidas = [
"Transferência interna",
"Saldo inicial",
"Pagamentos",
];
if (categoriasProtegidas.includes(categoria.name)) {
return {
success: false,
error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`,
};
}
const [deleted] = await db
.delete(categorias)
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning({ id: categorias.id });
const [deleted] = await db
.delete(categorias)
.where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id)))
.returning({ id: categorias.id });
if (!deleted) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
if (!deleted) {
return {
success: false,
error: "Categoria não encontrada.",
};
}
revalidateForEntity("categorias");
revalidateForEntity("categorias");
return { success: true, message: "Categoria removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
return { success: true, message: "Categoria removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -1,26 +1,26 @@
import type { CategoryType } from "@/components/categorias/types";
import { categorias, type Categoria } from "@/db/schema";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
import type { CategoryType } from "@/components/categorias/types";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export type CategoryData = {
id: string;
name: string;
type: CategoryType;
icon: string | null;
id: string;
name: string;
type: CategoryType;
icon: string | null;
};
export async function fetchCategoriesForUser(
userId: string
userId: string,
): Promise<CategoryData[]> {
const categoryRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
});
const categoryRows = await db.query.categorias.findMany({
where: eq(categorias.userId, userId),
});
return categoryRows.map((category: Categoria) => ({
id: category.id,
name: category.name,
type: category.type as CategoryType,
icon: category.icon,
}));
return categoryRows.map((category: Categoria) => ({
id: category.id,
name: category.name,
type: category.type as CategoryType,
icon: category.icon,
}));
}

View File

@@ -1,33 +1,33 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6 px-6">
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{/* Selected categories and counter */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-8 w-36 rounded-md" />
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14" />
</div>
</div>
return (
<main className="flex flex-col gap-6 px-6">
<Card className="h-auto">
<CardContent className="space-y-2.5">
<div className="space-y-2">
{/* Selected categories and counter */}
<div className="flex items-start justify-between gap-4">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-32 rounded-md" />
<Skeleton className="h-8 w-40 rounded-md" />
<Skeleton className="h-8 w-36 rounded-md" />
</div>
<div className="flex items-center gap-2 shrink-0 pt-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-14" />
</div>
</div>
{/* Category selector button */}
<Skeleton className="h-9 w-full rounded-md" />
</div>
{/* Category selector button */}
<Skeleton className="h-9 w-full rounded-md" />
</div>
{/* Chart */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</CardContent>
</Card>
</main>
);
{/* Chart */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</CardContent>
</Card>
</main>
);
}

View File

@@ -4,14 +4,14 @@ import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-histor
import { getCurrentPeriod } from "@/lib/utils/period";
export default async function HistoricoCategoriasPage() {
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const user = await getUser();
const currentPeriod = getCurrentPeriod();
const data = await fetchCategoryHistory(user.id, currentPeriod);
const data = await fetchCategoryHistory(user.id, currentPeriod);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
return (
<main>
<CategoryHistoryWidget data={data} />
</main>
);
}

View File

@@ -1,23 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiPriceTag3Line } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Categorias | Opensheets",
title: "Categorias | Opensheets",
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>
);
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiPriceTag3Line />}
title="Categorias"
subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário."
/>
{children}
</section>
);
}

View File

@@ -5,57 +5,54 @@ import { Skeleton } from "@/components/ui/skeleton";
* Layout: Header + Tabs + Grid de cards
*/
export default function CategoriasLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton
key={i}
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
/>
))}
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2 border-b">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton
key={i}
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
/>
))}
</div>
{/* Grid de cards de categorias */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-6 space-y-4"
>
{/* Ícone + Nome */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Grid de cards de categorias */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-2xl border p-6 space-y-4">
{/* Ícone + Nome */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
</div>
</div>
{/* Descrição */}
{i % 3 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
{/* Descrição */}
{i % 3 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
{/* Botões de ação */}
<div className="flex gap-2 pt-2 border-t">
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
</div>
</div>
))}
</div>
</div>
</div>
</main>
);
}

View File

@@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server";
import { fetchCategoriesForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId);
const userId = await getUserId();
const categories = await fetchCategoriesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} />
</main>
);
return (
<main className="flex flex-col items-start gap-6">
<CategoriesPage categories={categories} />
</main>
);
}