feat(relatorios): reorganizar páginas e criar componente CategoryIconBadge

- Renomear /relatorios/categorias para /relatorios/tendencias
- Renomear /relatorios/cartoes para /relatorios/uso-cartoes
- Criar componente CategoryIconBadge unificado com cores dinâmicas
- Atualizar cards de categorias com novo layout (ações no footer)
- Atualizar cards de orçamentos com CategoryIconBadge
- Adicionar tooltip detalhado nas células de tendências (valor anterior e diferença)
- Adicionar dot colorido (verde/vermelho) para indicar tipo de categoria

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-30 14:52:11 +00:00
parent 11f85e4b28
commit fd84a0d1ac
25 changed files with 611 additions and 404 deletions

View File

@@ -0,0 +1,12 @@
import { asc, eq } from "drizzle-orm";
import { type Categoria, categorias } from "@/db/schema";
import { db } from "@/lib/db";
export async function fetchUserCategories(
userId: string,
): Promise<Categoria[]> {
return db.query.categorias.findMany({
where: eq(categorias.userId, userId),
orderBy: [asc(categorias.name)],
});
}

View File

@@ -0,0 +1,23 @@
import { RiFileChartLine } from "@remixicon/react";
import PageDescription from "@/components/page-description";
export const metadata = {
title: "Tendências | Opensheets",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFileChartLine />}
title="Tendências"
subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,9 @@
import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton";
export default function Loading() {
return (
<main className="flex flex-col gap-6">
<CategoryReportSkeleton />
</main>
);
}

View File

@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import { CategoryReportPage } from "@/components/relatorios/category-report-page";
import type {
CategoryOption,
FilterState,
} from "@/components/relatorios/types";
import type { Categoria } from "@/db/schema";
import { getUserId } from "@/lib/auth/server";
import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data";
import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report";
import type { CategoryReportFilters } from "@/lib/relatorios/types";
import { validateDateRange } from "@/lib/relatorios/utils";
import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period";
import { fetchUserCategories } from "./data";
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
type PageProps = {
searchParams?: PageSearchParams;
};
const getSingleParam = (
params: Record<string, string | string[] | undefined> | undefined,
key: string,
): string | null => {
const value = params?.[key];
if (!value) return null;
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export default async function Page({ searchParams }: PageProps) {
// Get authenticated user
const userId = await getUserId();
// Resolve search params
const resolvedSearchParams = searchParams ? await searchParams : undefined;
// Extract query params
const inicioParam = getSingleParam(resolvedSearchParams, "inicio");
const fimParam = getSingleParam(resolvedSearchParams, "fim");
const categoriasParam = getSingleParam(resolvedSearchParams, "categorias");
// Calculate default period (last 6 months)
const currentPeriod = getCurrentPeriod();
const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current
// Use params or defaults
const startPeriod = inicioParam ?? defaultStartPeriod;
const endPeriod = fimParam ?? currentPeriod;
// Parse selected categories
const selectedCategoryIds = categoriasParam
? categoriasParam.split(",").filter(Boolean)
: [];
// Validate date range
const validation = validateDateRange(startPeriod, endPeriod);
if (!validation.isValid) {
// Redirect to default if validation fails
redirect(
`/relatorios/tendencias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`,
);
}
// Fetch all categories for the user
const categoriaRows = await fetchUserCategories(userId);
// Map to CategoryOption format
const categoryOptions: CategoryOption[] = categoriaRows.map(
(cat: Categoria): CategoryOption => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}),
);
// Build filters for data fetching
const filters: CategoryReportFilters = {
startPeriod,
endPeriod,
categoryIds:
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
};
// Fetch report data
const reportData = await fetchCategoryReport(userId, filters);
// Fetch chart data with same filters
const chartData = await fetchCategoryChartData(
userId,
startPeriod,
endPeriod,
selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
);
// Build initial filter state for client component
const initialFilters: FilterState = {
selectedCategories: selectedCategoryIds,
startPeriod,
endPeriod,
};
return (
<main className="flex flex-col gap-6">
<CategoryReportPage
initialData={reportData}
categories={categoryOptions}
initialFilters={initialFilters}
chartData={chartData}
/>
</main>
);
}