feat: implementar relatórios de categorias e substituir seleção de período por picker visual

BREAKING CHANGE: Remove feature de seleção de período das preferências do usuário

  Alterações principais:

  - Adiciona sistema completo de relatórios por categoria
    - Cria página /relatorios/categorias com filtros e visualizações
    - Implementa tabela e gráfico de evolução mensal
    - Adiciona funcionalidade de exportação de dados
    - Cria skeleton otimizado para melhor UX de loading

  - Remove feature de seleção de período das preferências
    - Deleta lib/user-preferences/period.ts
    - Remove colunas periodMonthsBefore e periodMonthsAfter do schema
    - Remove todas as referências em 16+ arquivos
    - Atualiza database schema via Drizzle

  - Substitui Select de período por MonthPicker visual
    - Implementa componente PeriodPicker reutilizável
    - Integra shadcn MonthPicker customizado (português, Remix icons)
    - Substitui createMonthOptions em todos os formulários
    - Mantém formato "YYYY-MM" no banco de dados

  - Melhora design da tabela de relatórios
    - Mescla colunas Categoria e Tipo em uma única coluna
    - Substitui badge de tipo por dot colorido discreto
    - Reduz largura da tabela em ~120px
    - Atualiza skeleton para refletir nova estrutura

  - Melhorias gerais de UI
    - Reduz espaçamento entre títulos da sidebar (p-2 → px-2 py-1)
    - Adiciona MonthNavigation para navegação entre períodos
    - Otimiza loading states com skeletons detalhados
This commit is contained in:
Felipe Coutinho
2026-01-04 03:03:09 +00:00
parent d192f47bc7
commit 4237062bde
54 changed files with 2987 additions and 472 deletions

View File

@@ -0,0 +1,180 @@
/**
* Data fetching function for Category Chart (based on selected filters)
*/
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { generatePeriodRange } from "./utils";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
export type CategoryChartData = {
months: string[]; // Short month labels (e.g., "JAN", "FEV")
categories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
};
export async function fetchCategoryChartData(
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[]
): Promise<CategoryChartData> {
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Map all categories
const allCategories = allCategoriesRows.map((cat: {
id: string;
name: string;
icon: string | null;
type: string;
}) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}));
// Process results into chart format
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
dataByPeriod: new Map(),
});
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
}
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
}
return dataPoint;
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type,
}));
return {
months,
categories,
chartData,
allCategories,
};
}

View File

@@ -0,0 +1,198 @@
/**
* Data fetching function for Category Report
*/
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
} from "./types";
import { calculatePercentageChange, generatePeriodRange } from "./utils";
/**
* Fetches category report data for multiple periods
*
* @param userId - User ID to filter data
* @param filters - Report filters (startPeriod, endPeriod, categoryIds)
* @returns Complete category report data
*/
export async function fetchCategoryReport(
userId: string,
filters: CategoryReportFilters
): Promise<CategoryReportData> {
const { startPeriod, endPeriod, categoryIds } = filters;
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
eq(categorias.type, "despesa"),
eq(categorias.type, "receita")
),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// Process results into CategoryReportData structure
const categoryMap = new Map<string, CategoryReportItem>();
const periodTotalsMap = new Map<string, number>();
// Initialize period totals
for (const period of periods) {
periodTotalsMap.set(period, 0);
}
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } = row;
// Get or create category item
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
monthlyData: new Map<string, MonthlyData>(),
total: 0,
});
}
const categoryItem = categoryMap.get(categoryId)!;
// Add monthly data (will calculate percentage later)
categoryItem.monthlyData.set(period, {
period,
amount,
previousAmount: 0, // Will be filled in next step
percentageChange: null, // Will be calculated in next step
});
// Update category total
categoryItem.total += amount;
// Update period total
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
periodTotalsMap.set(period, currentPeriodTotal + amount);
}
// Calculate percentage changes (compare with previous period)
for (const categoryItem of categoryMap.values()) {
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period)!;
if (i > 0) {
// Get previous period data
const prevPeriod = sortedPeriods[i - 1];
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
const previousAmount = prevMonthlyData?.amount ?? 0;
// Update with previous amount and calculate percentage
monthlyData.previousAmount = previousAmount;
monthlyData.percentageChange = calculatePercentageChange(
monthlyData.amount,
previousAmount
);
} else {
// First period - no comparison
monthlyData.previousAmount = 0;
monthlyData.percentageChange = null;
}
}
}
// Fill in missing periods with zero values
for (const categoryItem of categoryMap.values()) {
for (const period of periods) {
if (!categoryItem.monthlyData.has(period)) {
// Find previous period data for percentage calculation
const periodIndex = periods.indexOf(period);
let previousAmount = 0;
if (periodIndex > 0) {
const prevPeriod = periods[periodIndex - 1];
const prevData = categoryItem.monthlyData.get(prevPeriod);
previousAmount = prevData?.amount ?? 0;
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
// Convert to array and sort
const categories = Array.from(categoryMap.values());
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
}
// Then by total (descending)
return b.total - a.total;
});
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
}

52
lib/relatorios/types.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Types for Category Report feature
*/
/**
* Monthly data for a specific category in a specific period
*/
export type MonthlyData = {
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
};
/**
* Single category item in the report
*/
export type CategoryReportItem = {
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
};
/**
* Complete category report data structure
*/
export type CategoryReportData = {
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
};
/**
* Filters for category report query
*/
export type CategoryReportFilters = {
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
};
/**
* Validation result for date range
*/
export type DateRangeValidation = {
isValid: boolean;
error?: string;
};

131
lib/relatorios/utils.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Utility functions for Category Report feature
*/
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import type { DateRangeValidation } from "./types";
// Re-export for convenience
export { calculatePercentageChange };
/**
* Formats period string from "YYYY-MM" to "MMM/YYYY" format
* Example: "2025-01" -> "Jan/2025"
*
* @param period - Period in YYYY-MM format
* @returns Formatted period string
*/
export function formatPeriodLabel(period: string): string {
try {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1];
// Capitalize first letter and take first 3 chars
const shortMonth =
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
return `${shortMonth}/${year}`;
} catch {
return period; // Return original if parsing fails
}
}
/**
* Generates an array of periods between start and end (inclusive)
* Alias for buildPeriodRange from period utils
*
* @param startPeriod - Start period in YYYY-MM format
* @param endPeriod - End period in YYYY-MM format
* @returns Array of period strings in chronological order
*/
export function generatePeriodRange(
startPeriod: string,
endPeriod: string
): string[] {
return buildPeriodRange(startPeriod, endPeriod);
}
/**
* Validates that end date is >= start date and period is within limits
* Maximum allowed period: 24 months
*
* @param startPeriod - Start period in YYYY-MM format
* @param endPeriod - End period in YYYY-MM format
* @returns Validation result with error message if invalid
*/
export function validateDateRange(
startPeriod: string,
endPeriod: string
): DateRangeValidation {
try {
// Parse periods to validate format
const start = parsePeriod(startPeriod);
const end = parsePeriod(endPeriod);
// Check if end is before start
if (
end.year < start.year ||
(end.year === start.year && end.month < start.month)
) {
return {
isValid: false,
error: "A data final deve ser maior ou igual à data inicial",
};
}
// Calculate number of months between periods
const monthsDiff =
(end.year - start.year) * 12 + (end.month - start.month) + 1;
// Check if period exceeds 24 months
if (monthsDiff > 24) {
return {
isValid: false,
error: "O período máximo permitido é de 24 meses",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
}
/**
* Formats a number as Brazilian currency (R$ X.XXX,XX)
* Uses the shared currencyFormatter from formatting-helpers
*
* @param value - Numeric value to format
* @returns Formatted currency string
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
}
/**
* Formats percentage change for display
* Format: "±X%" or "±X.X%" (one decimal if < 10%)
*
* @param change - Percentage change value
* @returns Formatted percentage string
*/
export function formatPercentageChange(change: number | null): string {
if (change === null) return "-";
const absChange = Math.abs(change);
const sign = change >= 0 ? "+" : "-";
// Use one decimal place if less than 10%
const formatted =
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
return `${sign}${formatted}%`;
}

View File

@@ -1,32 +0,0 @@
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
export type PeriodPreferences = {
monthsBefore: number;
monthsAfter: number;
};
/**
* Fetches period preferences for a user
* @param userId - User ID
* @returns Period preferences with defaults if not found
*/
export async function fetchUserPeriodPreferences(
userId: string
): Promise<PeriodPreferences> {
const result = await db
.select({
periodMonthsBefore: schema.userPreferences.periodMonthsBefore,
periodMonthsAfter: schema.userPreferences.periodMonthsAfter,
})
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, userId))
.limit(1);
const preferences = result[0];
return {
monthsBefore: preferences?.periodMonthsBefore ?? 3,
monthsAfter: preferences?.periodMonthsAfter ?? 3,
};
}

View File

@@ -355,48 +355,3 @@ export function derivePeriodFromDate(value?: string | null): string {
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
}
// ============================================================================
// SELECT OPTIONS GENERATION
// ============================================================================
export type SelectOption = {
value: string;
label: string;
};
/**
* Creates month options for a select dropdown, centered around current month
* @param currentValue - Current period value to ensure it's included in options
* @param monthsBefore - Number of months before current month (default: 3)
* @param monthsAfter - Number of months after current month (default: same as monthsBefore)
* @returns Array of select options with formatted labels
* @example
* createMonthOptions() // -3 to +3
* createMonthOptions(undefined, 3) // -3 to +3
* createMonthOptions(undefined, 3, 6) // -3 to +6
*/
export function createMonthOptions(
currentValue?: string,
monthsBefore: number = 3,
monthsAfter?: number
): SelectOption[] {
const now = new Date();
const options: SelectOption[] = [];
const after = monthsAfter ?? monthsBefore; // If not specified, use same as before
for (let offset = -monthsBefore; offset <= after; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
options.push({ value, label: displayPeriod(value) });
}
// Include current value if not already in options
if (currentValue && !options.some((option) => option.value === currentValue)) {
options.push({
value: currentValue,
label: displayPeriod(currentValue),
});
}
return options.sort((a, b) => a.value.localeCompare(b.value));
}