mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
feat(reports): melhora notas, calendario e analises
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user