feat(reports): melhora notas, calendario e analises

This commit is contained in:
Felipe Coutinho
2026-03-09 17:14:04 +00:00
parent ada1377640
commit 6205dee42a
35 changed files with 429 additions and 590 deletions

51
lib/notes/formatters.ts Normal file
View File

@@ -0,0 +1,51 @@
type NoteTasksSummaryInput = {
type: string;
tasks?: Array<{ completed: boolean }> | null;
};
const NOTE_CREATED_AT_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "medium",
});
const NOTE_CREATED_AT_LONG_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
dateStyle: "long",
timeStyle: "short",
});
const parseNoteDate = (value: string | Date | null | undefined) => {
if (!value) {
return null;
}
const parsed = value instanceof Date ? value : new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
export const buildNoteDisplayTitle = (value: string | null | undefined) => {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : "Anotação sem título";
};
export const getNoteTasksSummary = (note: NoteTasksSummaryInput) => {
if (note.type !== "tarefa") {
return "Nota";
}
const tasks = note.tasks ?? [];
const completed = tasks.filter((task) => task.completed).length;
return `${completed}/${tasks.length} concluídas`;
};
export const formatNoteCreatedAt = (
value: string | Date | null | undefined,
) => {
const parsed = parseNoteDate(value);
return parsed ? NOTE_CREATED_AT_FORMATTER.format(parsed) : null;
};
export const formatNoteCreatedAtLong = (
value: string | Date | null | undefined,
) => {
const parsed = parseNoteDate(value);
return parsed ? NOTE_CREATED_AT_LONG_FORMATTER.format(parsed) : null;
};

View File

@@ -20,8 +20,13 @@ import {
} from "@/db/schema";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { formatDateOnly } from "@/lib/utils/date";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
import {
buildPeriodWindow,
formatCompactPeriodLabel,
getPreviousPeriod,
} from "@/lib/utils/period";
const DESPESA = "Despesa";
@@ -75,6 +80,49 @@ export type CartoesReportData = {
selectedCard: CardDetailData | null;
};
type CardRow = {
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: unknown;
status: string;
};
type CardUsageRow = {
cartaoId: string | null;
totalAmount: unknown;
};
type MonthlyUsageRow = {
period: string;
totalAmount: unknown;
};
type CategoryAmountRow = {
categoriaId: string | null;
totalAmount: unknown;
};
type CategoryInfoRow = {
id: string;
name: string;
icon: string | null;
};
type TopExpenseRow = {
id: string;
name: string;
amount: unknown;
purchaseDate: Date | string | null;
categoriaId: string | null;
};
type InvoiceStatusRow = {
period: string;
status: string | null;
};
export async function fetchCartoesReportData(
userId: string,
currentPeriod: string,
@@ -83,7 +131,7 @@ export async function fetchCartoesReportData(
const previousPeriod = getPreviousPeriod(currentPeriod);
// Fetch all active cards (not inactive)
const allCards = await db
const allCards = (await db
.select({
id: cartoes.id,
name: cartoes.name,
@@ -95,7 +143,7 @@ export async function fetchCartoesReportData(
.from(cartoes)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
)) as CardRow[];
if (allCards.length === 0) {
return {
@@ -110,7 +158,7 @@ export async function fetchCartoesReportData(
const cardIds = allCards.map((c) => c.id);
// Fetch current period usage by card (recorrente só conta quando a data da ocorrência já passou)
const currentUsageData = await db
const currentUsageData = (await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
@@ -130,10 +178,10 @@ export async function fetchCartoesReportData(
),
),
)
.groupBy(lancamentos.cartaoId);
.groupBy(lancamentos.cartaoId)) as CardUsageRow[];
// Fetch previous period usage by card
const previousUsageData = await db
const previousUsageData = (await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
@@ -149,7 +197,7 @@ export async function fetchCartoesReportData(
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
.groupBy(lancamentos.cartaoId)) as CardUsageRow[];
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
@@ -246,32 +294,12 @@ async function fetchCardDetail(
currentPeriod: string,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
const periods = buildPeriodWindow(currentPeriod, 12);
const startPeriod = periods[0];
const monthLabels = [
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
];
// Fetch monthly usage
const monthlyData = await db
const monthlyData = (await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
@@ -289,20 +317,19 @@ async function fetchCardDetail(
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
.orderBy(lancamentos.period)) as MonthlyUsageRow[];
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
const [year, month] = period.split("-");
return {
period,
periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`,
periodLabel: formatCompactPeriodLabel(period),
amount: Math.abs(safeToNumber(data?.totalAmount)),
};
});
// Fetch category breakdown for current period
const categoryData = await db
const categoryData = (await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
@@ -318,7 +345,7 @@ async function fetchCardDetail(
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.categoriaId);
.groupBy(lancamentos.categoriaId)) as CategoryAmountRow[];
// Fetch category names
const categoryIds = categoryData
@@ -327,15 +354,15 @@ async function fetchCardDetail(
const categoryNames =
categoryIds.length > 0
? await db
? ((await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))
: [];
.where(inArray(categorias.id, categoryIds))) as CategoryInfoRow[])
: ([] as CategoryInfoRow[]);
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
@@ -363,7 +390,7 @@ async function fetchCardDetail(
.slice(0, 10);
// Fetch top expenses for current period
const topExpensesData = await db
const topExpensesData = (await db
.select({
id: lancamentos.id,
name: lancamentos.name,
@@ -383,7 +410,7 @@ async function fetchCardDetail(
),
)
.orderBy(lancamentos.amount)
.limit(10);
.limit(10)) as TopExpenseRow[];
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
@@ -393,15 +420,18 @@ async function fetchCardDetail(
id: expense.id,
name: expense.name,
amount: Math.abs(safeToNumber(expense.amount)),
date: expense.purchaseDate
? new Date(expense.purchaseDate).toLocaleDateString("pt-BR")
: "",
date:
formatDateOnly(expense.purchaseDate, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) ?? "",
category: catInfo?.name || null,
};
});
// Fetch invoice status for last 6 months
const invoiceData = await db
const invoiceData = (await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
@@ -415,7 +445,7 @@ async function fetchCardDetail(
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);
.orderBy(faturas.period)) as InvoiceStatusRow[];
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);

View File

@@ -1,11 +1,10 @@
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import { formatPeriodMonthShort } from "@/lib/utils/period";
import { generatePeriodRange } from "./utils";
export type CategoryChartData = {
@@ -127,13 +126,7 @@ export async function fetchCategoryChartData(
}
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
1,
);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const monthLabel = formatPeriodMonthShort(period).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
@@ -146,15 +139,9 @@ export async function fetchCategoryChartData(
return dataPoint;
});
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(
Number.parseInt(year, 10),
Number.parseInt(month, 10) - 1,
1,
);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
const months = periods.map((period) =>
formatPeriodMonthShort(period).toUpperCase(),
);
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,

View File

@@ -1,9 +1,9 @@
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/contas/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { getAdminPagadorId } from "@/lib/pagadores/get-admin-id";
import { safeToNumber as toNumber } from "@/lib/utils/number";
import type {
CategoryReportData,
CategoryReportFilters,

View File

@@ -1,52 +1,7 @@
/**
* 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;
};
export type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
DateRangeValidation,
MonthlyData,
} from "@/lib/types/relatorios";

View File

@@ -1,6 +1,11 @@
import type { DateRangeValidation } from "@/lib/types/relatorios";
import { calculatePercentageChange } from "@/lib/utils/math";
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import type { DateRangeValidation } from "./types";
import { formatPercentageChange as formatPercentageChangeValue } from "@/lib/utils/percentage";
import {
buildPeriodRange,
formatShortPeriodLabel,
parsePeriod,
} from "@/lib/utils/period";
// Re-export for convenience
export { calculatePercentageChange };
@@ -14,14 +19,8 @@ export { calculatePercentageChange };
*/
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}`;
parsePeriod(period);
return formatShortPeriodLabel(period);
} catch {
return period; // Return original if parsing fails
}
@@ -102,14 +101,5 @@ export function validateDateRange(
* @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}%`;
return formatPercentageChangeValue(change);
}

61
lib/utils/calendario.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { CalendarDay, CalendarEvent } from "@/lib/types/calendario";
import { toDateOnlyString } from "@/lib/utils/date";
export const formatDateKey = (date: Date) => toDateOnlyString(date) ?? "";
const getWeekdayIndex = (date: Date) => {
const day = date.getUTCDay(); // 0 (domingo) - 6 (sábado)
// Ajusta para segunda-feira como primeiro dia
return day === 0 ? 6 : day - 1;
};
export const buildCalendarDays = ({
year,
monthIndex,
events,
}: {
year: number;
monthIndex: number;
events: Map<string, CalendarEvent[]>;
}): CalendarDay[] => {
const startOfMonth = new Date(Date.UTC(year, monthIndex, 1));
const offset = getWeekdayIndex(startOfMonth);
const startDate = new Date(Date.UTC(year, monthIndex, 1 - offset));
const totalCells = 42; // 6 semanas
const now = new Date();
const todayKey = formatDateKey(
new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())),
);
const days: CalendarDay[] = [];
for (let index = 0; index < totalCells; index += 1) {
const currentDate = new Date(startDate);
currentDate.setUTCDate(startDate.getUTCDate() + index);
const dateKey = formatDateKey(currentDate);
const isCurrentMonth = currentDate.getUTCMonth() === monthIndex;
const dateLabel = currentDate.getUTCDate().toString();
const eventsForDay = events.get(dateKey) ?? [];
days.push({
date: dateKey,
label: dateLabel,
isCurrentMonth,
isToday: dateKey === todayKey,
events: eventsForDay,
});
}
return days;
};
export const WEEK_DAYS_SHORT = [
"Seg",
"Ter",
"Qua",
"Qui",
"Sex",
"Sáb",
"Dom",
];