feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

113
lib/dashboard/accounts.ts Normal file
View File

@@ -0,0 +1,113 @@
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } 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, sql } from "drizzle-orm";
type RawDashboardAccount = {
id: string;
name: string;
accountType: string;
status: string;
logo: string | null;
initialBalance: string | number | null;
balanceMovements: unknown;
};
export type DashboardAccount = {
id: string;
name: string;
accountType: string;
status: string;
logo: string | null;
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
};
export type DashboardAccountsSnapshot = {
totalBalance: number;
accounts: DashboardAccount[];
};
export async function fetchDashboardAccounts(
userId: string
): Promise<DashboardAccountsSnapshot> {
const rows = await db
.select({
id: contas.id,
name: contas.name,
accountType: contas.accountType,
status: contas.status,
logo: contas.logo,
initialBalance: contas.initialBalance,
excludeFromBalance: contas.excludeFromBalance,
balanceMovements: sql<number>`
coalesce(
sum(
case
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
else ${lancamentos.amount}
end
),
0
)
`,
})
.from(contas)
.leftJoin(
lancamentos,
and(
eq(lancamentos.contaId, contas.id),
eq(lancamentos.userId, userId),
eq(lancamentos.isSettled, true)
)
)
.leftJoin(
pagadores,
eq(lancamentos.pagadorId, pagadores.id)
)
.where(
and(
eq(contas.userId, userId),
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
)
)
.groupBy(
contas.id,
contas.name,
contas.accountType,
contas.status,
contas.logo,
contas.initialBalance,
contas.excludeFromBalance
);
const accounts = rows
.map((row: RawDashboardAccount & { excludeFromBalance: boolean }): DashboardAccount => {
const initialBalance = toNumber(row.initialBalance);
const balanceMovements = toNumber(row.balanceMovements);
return {
id: row.id,
name: row.name,
accountType: row.accountType,
status: row.status,
logo: row.logo,
initialBalance,
balance: initialBalance + balanceMovements,
excludeFromBalance: row.excludeFromBalance,
};
})
.sort((a, b) => b.balance - a.balance);
const totalBalance = accounts
.filter(account => !account.excludeFromBalance)
.reduce((total, account) => total + account.balance, 0);
return {
totalBalance,
accounts,
};
}

106
lib/dashboard/boletos.ts Normal file
View File

@@ -0,0 +1,106 @@
"use server";
import { lancamentos, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, asc, eq } from "drizzle-orm";
const PAYMENT_METHOD_BOLETO = "Boleto";
type RawDashboardBoleto = {
id: string;
name: string;
amount: string | number | null;
dueDate: string | Date | null;
boletoPaymentDate: string | Date | null;
isSettled: boolean | null;
};
export type DashboardBoleto = {
id: string;
name: string;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
};
export type DashboardBoletosSnapshot = {
boletos: DashboardBoleto[];
totalPendingAmount: number;
pendingCount: number;
};
const toISODate = (value: Date | string | null) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value;
}
return null;
};
export async function fetchDashboardBoletos(
userId: string,
period: string
): Promise<DashboardBoletosSnapshot> {
const rows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
boletoPaymentDate: lancamentos.boletoPaymentDate,
isSettled: lancamentos.isSettled,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(pagadores.role, "admin")
)
)
.orderBy(
asc(lancamentos.isSettled),
asc(lancamentos.dueDate),
asc(lancamentos.name)
);
const boletos = rows.map((row: RawDashboardBoleto): DashboardBoleto => {
const amount = Math.abs(toNumber(row.amount));
return {
id: row.id,
name: row.name,
amount,
dueDate: toISODate(row.dueDate),
boletoPaymentDate: toISODate(row.boletoPaymentDate),
isSettled: Boolean(row.isSettled),
};
});
let totalPendingAmount = 0;
let pendingCount = 0;
for (const boleto of boletos) {
if (!boleto.isSettled) {
totalPendingAmount += boleto.amount;
pendingCount += 1;
}
}
return {
boletos,
totalPendingAmount,
pendingCount,
};
}

View File

@@ -0,0 +1,131 @@
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import type { CategoryType } from "@/lib/categorias/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { mapLancamentosData } from "@/lib/lancamentos/page-helpers";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { getPreviousPeriod } from "@/lib/utils/period";
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
type MappedLancamentos = ReturnType<typeof mapLancamentosData>;
export type CategoryDetailData = {
category: {
id: string;
name: string;
icon: string | null;
type: CategoryType;
};
period: string;
previousPeriod: string;
currentTotal: number;
previousTotal: number;
percentageChange: number | null;
transactions: MappedLancamentos;
};
const calculatePercentageChange = (
current: number,
previous: number
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return null;
return current > 0 ? 100 : -100;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
};
export async function fetchCategoryDetails(
userId: string,
categoryId: string,
period: string
): Promise<CategoryDetailData | null> {
const category = await db.query.categorias.findFirst({
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
});
if (!category) {
return null;
}
const previousPeriod = getPreviousPeriod(period);
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
const sanitizedNote = or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
);
const currentRows = await db.query.lancamentos.findMany({
where: and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(lancamentos.period, period),
sanitizedNote
),
with: {
pagador: true,
conta: true,
cartao: true,
categoria: true,
},
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
});
const filteredRows = currentRows.filter(
(row) => row.pagador?.role === PAGADOR_ROLE_ADMIN
);
const transactions = mapLancamentosData(filteredRows);
const currentTotal = transactions.reduce(
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
0
);
const [previousTotalRow] = await db
.select({
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.categoriaId, categoryId),
eq(lancamentos.transactionType, transactionType),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
sanitizedNote,
eq(lancamentos.period, previousPeriod)
)
);
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
const percentageChange = calculatePercentageChange(
currentTotal,
previousTotal
);
return {
category: {
id: category.id,
name: category.name,
icon: category.icon,
type: category.type as CategoryType,
},
period,
previousPeriod,
currentTotal,
previousTotal,
percentageChange,
transactions,
};
}

View File

@@ -0,0 +1,163 @@
import { categorias, lancamentos, orcamentos, 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 { getPreviousPeriod } from "@/lib/utils/period";
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { toNumber } from "@/lib/dashboard/common";
export type CategoryExpenseItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type ExpensesByCategoryData = {
categories: CategoryExpenseItem[];
currentTotal: number;
previousTotal: number;
};
const calculatePercentageChange = (
current: number,
previous: number
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
if (Math.abs(previous) < EPSILON) {
if (Math.abs(current) < EPSILON) return null;
return current > 0 ? 100 : -100;
}
const change = ((current - previous) / Math.abs(previous)) * 100;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
};
export async function fetchExpensesByCategory(
userId: string,
period: string
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca despesas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(
orcamentos,
and(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId)
)
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
// Busca despesas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
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(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "despesa"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(categorias.id);
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
for (const row of previousPeriodRows) {
const amount = Math.abs(toNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
}
// Calcula o total do período atual
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(toNumber(row.total));
}
// Monta os dados de cada categoria
const categories: CategoryExpenseItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(toNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount ? toNumber(row.budgetAmount) : null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
}

View File

@@ -0,0 +1,147 @@
import { categorias, lancamentos, orcamentos, 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 { getPreviousPeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { and, eq, isNull, or, sql } from "drizzle-orm";
export type CategoryIncomeItem = {
categoryId: string;
categoryName: string;
categoryIcon: string | null;
currentAmount: number;
previousAmount: number;
percentageChange: number | null;
percentageOfTotal: number;
budgetAmount: number | null;
budgetUsedPercentage: number | null;
};
export type IncomeByCategoryData = {
categories: CategoryIncomeItem[];
currentTotal: number;
previousTotal: number;
};
export async function fetchIncomeByCategory(
userId: string,
period: string
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
// Busca receitas do período atual agrupadas por categoria
const currentPeriodRows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
budgetAmount: orcamentos.amount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(
orcamentos,
and(
eq(orcamentos.categoriaId, categorias.id),
eq(orcamentos.period, period),
eq(orcamentos.userId, userId)
)
)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
// Busca receitas do período anterior agrupadas por categoria
const previousPeriodRows = await db
.select({
categoryId: categorias.id,
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(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(categorias.type, "receita"),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(categorias.id);
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
for (const row of previousPeriodRows) {
const amount = Math.abs(safeToNumber(row.total));
previousMap.set(row.categoryId, amount);
previousTotal += amount;
}
// Calcula o total do período atual
let currentTotal = 0;
for (const row of currentPeriodRows) {
currentTotal += Math.abs(safeToNumber(row.total));
}
// Monta os dados de cada categoria
const categories: CategoryIncomeItem[] = currentPeriodRows.map((row) => {
const currentAmount = Math.abs(safeToNumber(row.total));
const previousAmount = previousMap.get(row.categoryId) ?? 0;
const percentageChange = calculatePercentageChange(
currentAmount,
previousAmount
);
const percentageOfTotal =
currentTotal > 0 ? (currentAmount / currentTotal) * 100 : 0;
const budgetAmount = row.budgetAmount ? safeToNumber(row.budgetAmount) : null;
const budgetUsedPercentage =
budgetAmount && budgetAmount > 0
? (currentAmount / budgetAmount) * 100
: null;
return {
categoryId: row.categoryId,
categoryName: row.categoryName,
categoryIcon: row.categoryIcon,
currentAmount,
previousAmount,
percentageChange,
percentageOfTotal,
budgetAmount,
budgetUsedPercentage,
};
});
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
}

13
lib/dashboard/common.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Common utilities and helpers for dashboard queries
*/
import { safeToNumber } from "@/lib/utils/number";
import { calculatePercentageChange } from "@/lib/utils/math";
export { safeToNumber, calculatePercentageChange };
/**
* Alias for backward compatibility - dashboard uses "toNumber" naming
*/
export const toNumber = safeToNumber;

View File

@@ -0,0 +1,96 @@
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
export type InstallmentExpense = {
id: string;
name: string;
amount: number;
paymentMethod: string;
currentInstallment: number | null;
installmentCount: number | null;
dueDate: Date | null;
purchaseDate: Date;
period: string;
};
export type InstallmentExpensesData = {
expenses: InstallmentExpense[];
};
export async function fetchInstallmentExpenses(
userId: string,
period: string
): Promise<InstallmentExpensesData> {
const rows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
currentInstallment: lancamentos.currentInstallment,
installmentCount: lancamentos.installmentCount,
dueDate: lancamentos.dueDate,
purchaseDate: lancamentos.purchaseDate,
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
const expenses = rows
.map(
(row): InstallmentExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
paymentMethod: row.paymentMethod,
currentInstallment: row.currentInstallment,
installmentCount: row.installmentCount,
dueDate: row.dueDate ?? null,
purchaseDate: row.purchaseDate,
period: row.period,
})
)
.sort((a, b) => {
// Calcula parcelas restantes para cada item
const remainingA =
a.installmentCount && a.currentInstallment
? a.installmentCount - a.currentInstallment
: 0;
const remainingB =
b.installmentCount && b.currentInstallment
? b.installmentCount - b.currentInstallment
: 0;
// Ordena do menor número de parcelas restantes para o maior
return remainingA - remainingB;
});
return {
expenses,
};
}

View File

@@ -0,0 +1,66 @@
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
export type RecurringExpense = {
id: string;
name: string;
amount: number;
paymentMethod: string;
recurrenceCount: number | null;
};
export type RecurringExpensesData = {
expenses: RecurringExpense[];
};
export async function fetchRecurringExpenses(
userId: string,
period: string
): Promise<RecurringExpensesData> {
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
recurrenceCount: lancamentos.recurrenceCount,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Recorrente"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt));
const expenses = results.map((row): RecurringExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
paymentMethod: row.paymentMethod,
recurrenceCount: row.recurrenceCount,
}));
return {
expenses,
};
}

View File

@@ -0,0 +1,84 @@
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { toNumber } from "@/lib/dashboard/common";
export type TopExpense = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
paymentMethod: string;
logo?: string | null;
};
export type TopExpensesData = {
expenses: TopExpense[];
};
export async function fetchTopExpenses(
userId: string,
period: string,
cardOnly: boolean = false
): Promise<TopExpensesData> {
const conditions = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
),
];
// Se cardOnly for true, filtra apenas pagamentos com cartão
if (cardOnly) {
conditions.push(eq(lancamentos.paymentMethod, "Cartão de Crédito"));
}
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
paymentMethod: lancamentos.paymentMethod,
cartaoId: lancamentos.cartaoId,
contaId: lancamentos.contaId,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(and(...conditions))
.orderBy(asc(lancamentos.amount))
.limit(10);
const expenses = results.map(
(row): TopExpense => ({
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
paymentMethod: row.paymentMethod,
logo: row.cardLogo ?? row.accountLogo ?? null,
})
);
return {
expenses,
};
}

View File

@@ -0,0 +1,82 @@
import { fetchDashboardAccounts } from "./accounts";
import { fetchDashboardBoletos } from "./boletos";
import { fetchExpensesByCategory } from "./categories/expenses-by-category";
import { fetchIncomeByCategory } from "./categories/income-by-category";
import { fetchInstallmentExpenses } from "./expenses/installment-expenses";
import { fetchRecurringExpenses } from "./expenses/recurring-expenses";
import { fetchTopExpenses } from "./expenses/top-expenses";
import { fetchIncomeExpenseBalance } from "./income-expense-balance";
import { fetchDashboardInvoices } from "./invoices";
import { fetchDashboardCardMetrics } from "./metrics";
import { fetchDashboardNotifications } from "./notifications";
import { fetchPaymentConditions } from "./payments/payment-conditions";
import { fetchPaymentMethods } from "./payments/payment-methods";
import { fetchPaymentStatus } from "./payments/payment-status";
import { fetchPurchasesByCategory } from "./purchases-by-category";
import { fetchRecentTransactions } from "./recent-transactions";
import { fetchTopEstablishments } from "./top-establishments";
export async function fetchDashboardData(userId: string, period: string) {
const [
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
notificationsSnapshot,
paymentStatusData,
incomeExpenseBalanceData,
recentTransactionsData,
paymentConditionsData,
paymentMethodsData,
recurringExpensesData,
installmentExpensesData,
topEstablishmentsData,
topExpensesAll,
topExpensesCardOnly,
purchasesByCategoryData,
incomeByCategoryData,
expensesByCategoryData,
] = await Promise.all([
fetchDashboardCardMetrics(userId, period),
fetchDashboardAccounts(userId),
fetchDashboardInvoices(userId, period),
fetchDashboardBoletos(userId, period),
fetchDashboardNotifications(userId, period),
fetchPaymentStatus(userId, period),
fetchIncomeExpenseBalance(userId, period),
fetchRecentTransactions(userId, period),
fetchPaymentConditions(userId, period),
fetchPaymentMethods(userId, period),
fetchRecurringExpenses(userId, period),
fetchInstallmentExpenses(userId, period),
fetchTopEstablishments(userId, period),
fetchTopExpenses(userId, period, false),
fetchTopExpenses(userId, period, true),
fetchPurchasesByCategory(userId, period),
fetchIncomeByCategory(userId, period),
fetchExpensesByCategory(userId, period),
]);
return {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
notificationsSnapshot,
paymentStatusData,
incomeExpenseBalanceData,
recentTransactionsData,
paymentConditionsData,
paymentMethodsData,
recurringExpensesData,
installmentExpensesData,
topEstablishmentsData,
topExpensesAll,
topExpensesCardOnly,
purchasesByCategoryData,
incomeByCategoryData,
expensesByCategoryData,
};
}
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

@@ -0,0 +1,138 @@
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, sql } from "drizzle-orm";
export type MonthData = {
month: string;
monthLabel: string;
income: number;
expense: number;
balance: number;
};
export type IncomeExpenseBalanceData = {
months: MonthData[];
};
const MONTH_LABELS: Record<string, string> = {
"01": "jan",
"02": "fev",
"03": "mar",
"04": "abr",
"05": "mai",
"06": "jun",
"07": "jul",
"08": "ago",
"09": "set",
"10": "out",
"11": "nov",
"12": "dez",
};
const generateLast6Months = (currentPeriod: string): string[] => {
const [yearStr, monthStr] = currentPeriod.split("-");
let year = Number.parseInt(yearStr ?? "", 10);
let month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
const now = new Date();
year = now.getFullYear();
month = now.getMonth() + 1;
}
const periods: string[] = [];
for (let i = 5; i >= 0; i--) {
let targetMonth = month - i;
let targetYear = year;
while (targetMonth <= 0) {
targetMonth += 12;
targetYear -= 1;
}
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
}
return periods;
};
export async function fetchIncomeExpenseBalance(
userId: string,
currentPeriod: string
): Promise<IncomeExpenseBalanceData> {
const periods = generateLast6Months(currentPeriod);
const results = await Promise.all(
periods.map(async (period) => {
// Busca receitas do período
const [incomeRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
)
);
// Busca despesas do período
const [expenseRow] = await db
.select({
total: sql<number>`
coalesce(
sum(${lancamentos.amount}),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
)
);
const income = Math.abs(toNumber(incomeRow?.total));
const expense = Math.abs(toNumber(expenseRow?.total));
const balance = income - expense;
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
income,
expense,
balance,
};
})
);
return {
months: results,
};
}

279
lib/dashboard/invoices.ts Normal file
View File

@@ -0,0 +1,279 @@
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
} from "@/lib/faturas";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
type RawDashboardInvoice = {
invoiceId: string | null;
cardId: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
logo: string | null;
dueDay: string;
period: string | null;
paymentStatus: string | null;
totalAmount: string | number | null;
transactionCount: string | number | null;
invoiceCreatedAt: Date | null;
};
export type InvoicePagadorBreakdown = {
pagadorId: string | null;
pagadorName: string;
pagadorAvatar: string | null;
amount: number;
};
export type DashboardInvoice = {
id: string;
cardId: string;
cardName: string;
cardBrand: string | null;
cardStatus: string | null;
logo: string | null;
dueDay: string;
period: string;
paymentStatus: InvoicePaymentStatus;
totalAmount: number;
paidAt: string | null;
pagadorBreakdown: InvoicePagadorBreakdown[];
};
export type DashboardInvoicesSnapshot = {
invoices: DashboardInvoice[];
totalPending: number;
};
const toISODate = (value: Date | string | null | undefined) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value.slice(0, 10);
}
return null;
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
const buildFallbackId = (cardId: string, period: string) =>
`${cardId}:${period}`;
export async function fetchDashboardInvoices(
userId: string,
period: string
): Promise<DashboardInvoicesSnapshot> {
const paymentRows = await db
.select({
note: lancamentos.note,
purchaseDate: lancamentos.purchaseDate,
createdAt: lancamentos.createdAt,
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)
)
);
const paymentMap = new Map<string, string>();
for (const row of paymentRows) {
const note = row.note;
if (!note || !note.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) {
continue;
}
const parts = note.split(":");
if (parts.length < 3) {
continue;
}
const cardIdPart = parts[1];
const periodPart = parts[2];
if (!cardIdPart || !periodPart) {
continue;
}
const key = `${cardIdPart}:${periodPart}`;
const resolvedDate =
row.purchaseDate instanceof Date && !Number.isNaN(row.purchaseDate.valueOf())
? row.purchaseDate
: row.createdAt;
const isoDate = toISODate(resolvedDate);
if (!isoDate) {
continue;
}
const existing = paymentMap.get(key);
if (!existing || existing < isoDate) {
paymentMap.set(key, isoDate);
}
}
const [rows, breakdownRows] = await Promise.all([
db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
logo: cartoes.logo,
dueDay: cartoes.dueDay,
period: faturas.period,
paymentStatus: faturas.paymentStatus,
invoiceCreatedAt: faturas.createdAt,
totalAmount: sql<number | null>`
COALESCE(SUM(${lancamentos.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
})
.from(cartoes)
.leftJoin(
faturas,
and(
eq(faturas.cartaoId, cartoes.id),
eq(faturas.userId, userId),
eq(faturas.period, period)
)
)
.leftJoin(
lancamentos,
and(
eq(lancamentos.cartaoId, cartoes.id),
eq(lancamentos.userId, userId),
eq(lancamentos.period, period)
)
)
.where(eq(cartoes.userId, userId))
.groupBy(
faturas.id,
cartoes.id,
cartoes.name,
cartoes.brand,
cartoes.status,
cartoes.logo,
cartoes.dueDay,
faturas.period,
faturas.paymentStatus
),
db
.select({
cardId: lancamentos.cartaoId,
period: lancamentos.period,
pagadorId: lancamentos.pagadorId,
pagadorName: pagadores.name,
pagadorAvatar: pagadores.avatarUrl,
amount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
isNotNull(lancamentos.cartaoId)
)
)
.groupBy(
lancamentos.cartaoId,
lancamentos.period,
lancamentos.pagadorId,
pagadores.name,
pagadores.avatarUrl
),
]);
const breakdownMap = new Map<string, InvoicePagadorBreakdown[]>();
for (const row of breakdownRows) {
if (!row.cardId) {
continue;
}
const resolvedPeriod = row.period ?? period;
const amount = Math.abs(toNumber(row.amount));
if (amount <= 0) {
continue;
}
const key = `${row.cardId}:${resolvedPeriod}`;
const current = breakdownMap.get(key) ?? [];
current.push({
pagadorId: row.pagadorId ?? null,
pagadorName: row.pagadorName?.trim() || "Sem pagador",
pagadorAvatar: row.pagadorAvatar ?? null,
amount,
});
breakdownMap.set(key, current);
}
const invoices = rows
.map((row: RawDashboardInvoice | null) => {
if (!row) return null;
const totalAmount = toNumber(row.totalAmount);
const transactionCount = toNumber(row.transactionCount);
const paymentStatus = isInvoiceStatus(row.paymentStatus)
? row.paymentStatus
: INVOICE_PAYMENT_STATUS.PENDING;
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
if (!shouldInclude) {
return null;
}
const resolvedPeriod = row.period ?? period;
const paymentKey = `${row.cardId}:${resolvedPeriod}`;
const paidAt =
paymentStatus === INVOICE_PAYMENT_STATUS.PAID
? paymentMap.get(paymentKey) ??
toISODate(row.invoiceCreatedAt)
: null;
return {
id: row.invoiceId ?? buildFallbackId(row.cardId, period),
cardId: row.cardId,
cardName: row.cardName,
cardBrand: row.cardBrand,
cardStatus: row.cardStatus,
logo: row.logo,
dueDay: row.dueDay,
period: resolvedPeriod,
paymentStatus,
totalAmount,
paidAt,
pagadorBreakdown: (
breakdownMap.get(`${row.cardId}:${resolvedPeriod}`) ?? []
).sort((a, b) => b.amount - a.amount),
} satisfies DashboardInvoice;
})
.filter((invoice): invoice is DashboardInvoice => invoice !== null)
.sort((a, b) => {
// Ordena do maior valor para o menor
return Math.abs(b.totalAmount) - Math.abs(a.totalAmount);
});
const totalPending = invoices.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
return total;
}
return total + invoice.totalAmount;
}, 0);
return {
invoices,
totalPending,
};
}

155
lib/dashboard/metrics.ts Normal file
View File

@@ -0,0 +1,155 @@
import { 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 {
getPreviousPeriod,
buildPeriodRange,
comparePeriods,
} from "@/lib/utils/period";
import { safeToNumber } from "@/lib/utils/number";
import { and, asc, eq, ilike, isNull, lte, not, or, sum, ne } from "drizzle-orm";
const RECEITA = "Receita";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
type MetricPair = {
current: number;
previous: number;
};
export type DashboardCardMetrics = {
period: string;
previousPeriod: string;
receitas: MetricPair;
despesas: MetricPair;
balanco: MetricPair;
previsto: MetricPair;
};
type PeriodTotals = {
receitas: number;
despesas: number;
balanco: number;
};
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
balanco: 0,
});
const ensurePeriodTotals = (
store: Map<string, PeriodTotals>,
period: string
): PeriodTotals => {
if (!store.has(period)) {
store.set(period, createEmptyTotals());
}
const totals = store.get(period);
// This should always exist since we just set it above
if (!totals) {
const emptyTotals = createEmptyTotals();
store.set(period, emptyTotals);
return emptyTotals;
}
return totals;
};
// Re-export for backward compatibility
export { getPreviousPeriod };
export async function fetchDashboardCardMetrics(
userId: string,
period: string
): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period);
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
lte(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
not(
ilike(
lancamentos.note,
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`
)
)
)
)
)
.groupBy(lancamentos.period, lancamentos.transactionType)
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
const periodTotals = new Map<string, PeriodTotals>();
for (const row of rows) {
if (!row.period) continue;
const totals = ensurePeriodTotals(periodTotals, row.period);
const total = safeToNumber(row.totalAmount);
if (row.transactionType === RECEITA) {
totals.receitas += total;
} else if (row.transactionType === DESPESA) {
totals.despesas += Math.abs(total);
}
}
ensurePeriodTotals(periodTotals, period);
ensurePeriodTotals(periodTotals, previousPeriod);
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startPeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const periodRange = buildPeriodRange(startPeriod, period);
const forecastByPeriod = new Map<string, number>();
let runningForecast = 0;
for (const key of periodRange) {
const totals = ensurePeriodTotals(periodTotals, key);
totals.balanco = totals.receitas - totals.despesas;
runningForecast += totals.balanco;
forecastByPeriod.set(key, runningForecast);
}
const currentTotals = ensurePeriodTotals(periodTotals, period);
const previousTotals = ensurePeriodTotals(periodTotals, previousPeriod);
return {
period,
previousPeriod,
receitas: {
current: currentTotals.receitas,
previous: previousTotals.receitas,
},
despesas: {
current: currentTotals.despesas,
previous: previousTotals.despesas,
},
balanco: {
current: currentTotals.balanco,
previous: previousTotals.balanco,
},
previsto: {
current: forecastByPeriod.get(period) ?? runningForecast,
previous: forecastByPeriod.get(previousPeriod) ?? 0,
},
};
}

View File

@@ -0,0 +1,373 @@
"use server";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { INVOICE_PAYMENT_STATUS } from "@/lib/faturas";
import { and, eq, lt, sql } from "drizzle-orm";
export type NotificationType = "overdue" | "due_soon";
export type DashboardNotification = {
id: string;
type: "invoice" | "boleto";
name: string;
dueDate: string;
status: NotificationType;
amount: number;
period?: string;
showAmount: boolean; // Controla se o valor deve ser exibido no card
};
export type DashboardNotificationsSnapshot = {
notifications: DashboardNotification[];
totalCount: number;
};
const PAYMENT_METHOD_BOLETO = "Boleto";
/**
* Calcula a data de vencimento de uma fatura baseado no período e dia de vencimento
* @param period Período no formato YYYY-MM
* @param dueDay Dia do vencimento (1-31)
* @returns Data de vencimento no formato YYYY-MM-DD
*/
function calculateDueDate(period: string, dueDay: string): string {
const [year, month] = period.split("-");
const yearNumber = Number(year);
const monthNumber = Number(month);
const hasValidMonth =
Number.isInteger(yearNumber) &&
Number.isInteger(monthNumber) &&
monthNumber >= 1 &&
monthNumber <= 12;
const daysInMonth = hasValidMonth
? new Date(yearNumber, monthNumber, 0).getDate()
: null;
const dueDayNumber = Number(dueDay);
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
const clampedDay =
hasValidMonth && hasValidDueDay && daysInMonth
? Math.min(dueDayNumber, daysInMonth)
: hasValidDueDay
? dueDayNumber
: null;
const day = clampedDay
? String(clampedDay).padStart(2, "0")
: dueDay.padStart(2, "0");
const normalizedMonth =
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
return `${year}-${normalizedMonth}-${day}`;
}
/**
* Normaliza uma data para o início do dia em UTC (00:00:00)
*/
function normalizeDate(date: Date): Date {
return new Date(Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
0, 0, 0, 0
));
}
/**
* Converte string "YYYY-MM-DD" para Date em UTC (evita problemas de timezone)
*/
function parseUTCDate(dateString: string): Date {
const [year, month, day] = dateString.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Verifica se uma data está atrasada (antes do dia atual, não incluindo hoje)
*/
function isOverdue(dueDate: string, today: Date): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
return dueNormalized < today;
}
/**
* Verifica se uma data vence nos próximos X dias (incluindo hoje)
* Exemplo: Se hoje é dia 4 e daysThreshold = 5, retorna true para datas de 4 a 8
*/
function isDueWithinDays(
dueDate: string,
today: Date,
daysThreshold: number
): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
// Data limite: hoje + daysThreshold dias (em UTC)
const limitDate = new Date(today);
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
return dueNormalized >= today && dueNormalized <= limitDate;
}
/**
* Busca todas as notificações do dashboard
*
* Regras:
* - Períodos anteriores: TODOS os não pagos (sempre status "atrasado")
* - Período atual: Itens atrasados + os que vencem nos próximos dias (sem mostrar valor)
*
* Status:
* - "overdue": vencimento antes do dia atual (ou qualquer período anterior)
* - "due_soon": vencimento no dia atual ou nos próximos dias
*/
export async function fetchDashboardNotifications(
userId: string,
currentPeriod: string
): Promise<DashboardNotificationsSnapshot> {
const today = normalizeDate(new Date());
const DAYS_THRESHOLD = 5;
// Buscar faturas pendentes de períodos anteriores
// Apenas faturas com registro na tabela (períodos antigos devem ter sido finalizados)
const overdueInvoices = await db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
dueDay: cartoes.dueDay,
period: faturas.period,
totalAmount: sql<number | null>`
COALESCE(
(SELECT SUM(${lancamentos.amount})
FROM ${lancamentos}
WHERE ${lancamentos.cartaoId} = ${cartoes.id}
AND ${lancamentos.period} = ${faturas.period}
AND ${lancamentos.userId} = ${faturas.userId}),
0
)
`,
})
.from(faturas)
.innerJoin(cartoes, eq(faturas.cartaoId, cartoes.id))
.where(
and(
eq(faturas.userId, userId),
eq(faturas.paymentStatus, INVOICE_PAYMENT_STATUS.PENDING),
lt(faturas.period, currentPeriod)
)
);
// Buscar faturas do período atual
// Usa LEFT JOIN para incluir cartões com lançamentos mesmo sem registro em faturas
const currentInvoices = await db
.select({
invoiceId: faturas.id,
cardId: cartoes.id,
cardName: cartoes.name,
dueDay: cartoes.dueDay,
period: sql<string>`COALESCE(${faturas.period}, ${currentPeriod})`,
paymentStatus: faturas.paymentStatus,
totalAmount: sql<number | null>`
COALESCE(SUM(${lancamentos.amount}), 0)
`,
transactionCount: sql<number | null>`COUNT(${lancamentos.id})`,
})
.from(cartoes)
.leftJoin(
faturas,
and(
eq(faturas.cartaoId, cartoes.id),
eq(faturas.userId, userId),
eq(faturas.period, currentPeriod)
)
)
.leftJoin(
lancamentos,
and(
eq(lancamentos.cartaoId, cartoes.id),
eq(lancamentos.userId, userId),
eq(lancamentos.period, currentPeriod)
)
)
.where(eq(cartoes.userId, userId))
.groupBy(
faturas.id,
cartoes.id,
cartoes.name,
cartoes.dueDay,
faturas.period,
faturas.paymentStatus
);
// Buscar boletos não pagos
const boletosRows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
dueDate: lancamentos.dueDate,
period: lancamentos.period,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
eq(lancamentos.isSettled, false),
eq(pagadores.role, "admin")
)
);
const notifications: DashboardNotification[] = [];
// Processar faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const amount =
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
notifications.push({
id: notificationId,
type: "invoice",
name: invoice.cardName,
dueDate,
status: "overdue",
amount: Math.abs(amount),
period: invoice.period,
showAmount: true, // Mostrar valor para itens de períodos anteriores
});
}
// Processar faturas do período atual (atrasadas + vencimento iminente)
for (const invoice of currentInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
const amount =
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const transactionCount =
typeof invoice.transactionCount === "number"
? invoice.transactionCount
: Number(invoice.transactionCount) || 0;
const paymentStatus = invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
// Ignora se não tem lançamentos e não tem registro de fatura
const shouldInclude =
transactionCount > 0 ||
Math.abs(amount) > 0 ||
invoice.invoiceId !== null;
if (!shouldInclude) continue;
// Ignora se já foi paga
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
notifications.push({
id: notificationId,
type: "invoice",
name: invoice.cardName,
dueDate,
status: invoiceIsOverdue ? "overdue" : "due_soon",
amount: Math.abs(amount),
period: invoice.period,
showAmount: invoiceIsOverdue,
});
}
// Processar boletos
for (const boleto of boletosRows) {
if (!boleto.dueDate) continue;
// Converter para string no formato YYYY-MM-DD (UTC)
const dueDate =
boleto.dueDate instanceof Date
? `${boleto.dueDate.getUTCFullYear()}-${String(boleto.dueDate.getUTCMonth() + 1).padStart(2, "0")}-${String(boleto.dueDate.getUTCDate()).padStart(2, "0")}`
: boleto.dueDate;
const boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
// Período anterior: incluir todos (sempre atrasado)
if (isOldPeriod) {
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
name: boleto.name,
dueDate,
status: "overdue",
amount: Math.abs(amount),
period: boleto.period,
showAmount: true, // Mostrar valor para períodos anteriores
});
}
// Período atual: incluir atrasados e os que vencem em breve (sem valor)
else if (isCurrentPeriod && (boletoIsOverdue || boletoIsDueSoon)) {
const status: NotificationType = boletoIsOverdue ? "overdue" : "due_soon";
const amount =
typeof boleto.amount === "number"
? boleto.amount
: Number(boleto.amount) || 0;
notifications.push({
id: `boleto-${boleto.id}`,
type: "boleto",
name: boleto.name,
dueDate,
status,
amount: Math.abs(amount),
period: boleto.period,
showAmount: boletoIsOverdue,
});
}
}
// Ordenar: atrasados primeiro, depois por data de vencimento
notifications.sort((a, b) => {
if (a.status === "overdue" && b.status !== "overdue") return -1;
if (a.status !== "overdue" && b.status === "overdue") return 1;
return a.dueDate.localeCompare(b.dueDate);
});
return {
notifications,
totalCount: notifications.length,
};
}

View File

@@ -0,0 +1,79 @@
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, isNull, or, sql } from "drizzle-orm";
export type PaymentConditionSummary = {
condition: string;
amount: number;
percentage: number;
transactions: number;
};
export type PaymentConditionsData = {
conditions: PaymentConditionSummary[];
};
export async function fetchPaymentConditions(
userId: string,
period: string
): Promise<PaymentConditionsData> {
const rows = await db
.select({
condition: lancamentos.condition,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.groupBy(lancamentos.condition);
const summaries = rows.map((row) => {
const totalAmount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
return {
condition: row.condition,
amount: totalAmount,
transactions,
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const conditions = summaries
.map((item) => ({
condition: item.condition,
amount: item.amount,
transactions: item.transactions,
percentage:
overallTotal > 0
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
return {
conditions,
};
}

View File

@@ -0,0 +1,79 @@
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, isNull, or, sql } from "drizzle-orm";
export type PaymentMethodSummary = {
paymentMethod: string;
amount: number;
percentage: number;
transactions: number;
};
export type PaymentMethodsData = {
methods: PaymentMethodSummary[];
};
export async function fetchPaymentMethods(
userId: string,
period: string
): Promise<PaymentMethodsData> {
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
transactions: sql<number>`count(${lancamentos.id})`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.groupBy(lancamentos.paymentMethod);
const summaries = rows.map((row) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
return {
paymentMethod: row.paymentMethod,
amount,
transactions,
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
const methods = summaries
.map((item) => ({
paymentMethod: item.paymentMethod,
amount: item.amount,
transactions: item.transactions,
percentage:
overallTotal > 0
? Number(((item.amount / overallTotal) * 100).toFixed(2))
: 0,
}))
.sort((a, b) => b.amount - a.amount);
return {
methods,
};
}

View File

@@ -0,0 +1,120 @@
import { lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { eq, and, sql } from "drizzle-orm";
export type PaymentStatusCategory = {
total: number;
confirmed: number;
pending: number;
};
export type PaymentStatusData = {
income: PaymentStatusCategory;
expenses: PaymentStatusCategory;
};
export async function fetchPaymentStatus(
userId: string,
period: string
): Promise<PaymentStatusData> {
// Busca receitas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const incomeResult = await db
.select({
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Receita"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
)
);
// Busca despesas confirmadas e pendentes para o período do pagador admin
// Exclui lançamentos de pagamento de fatura (para evitar contagem duplicada)
const expensesResult = await db
.select({
confirmed: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = true then ${lancamentos.amount}
else 0
end
),
0
)
`,
pending: sql<number>`
coalesce(
sum(
case
when ${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null then ${lancamentos.amount}
else 0
end
),
0
)
`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, "admin"),
sql`(${lancamentos.note} IS NULL OR ${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`})`
)
);
const incomeData = incomeResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedIncome = toNumber(incomeData.confirmed);
const pendingIncome = toNumber(incomeData.pending);
const expensesData = expensesResult[0] ?? { confirmed: 0, pending: 0 };
const confirmedExpenses = toNumber(expensesData.confirmed);
const pendingExpenses = toNumber(expensesData.pending);
return {
income: {
total: confirmedIncome + pendingIncome,
confirmed: confirmedIncome,
pending: pendingIncome,
},
expenses: {
total: confirmedExpenses + pendingExpenses,
confirmed: confirmedExpenses,
pending: pendingExpenses,
},
};
}

View File

@@ -0,0 +1,145 @@
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
export type CategoryOption = {
id: string;
name: string;
type: string;
};
export type CategoryTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
logo: string | null;
};
export type PurchasesByCategoryData = {
categories: CategoryOption[];
transactionsByCategory: Record<string, CategoryTransaction[]>;
};
const shouldIncludeTransaction = (name: string) => {
const normalized = name.trim().toLowerCase();
if (normalized === "saldo inicial") {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
return true;
};
export async function fetchPurchasesByCategory(
userId: string,
period: string
): Promise<PurchasesByCategoryData> {
const transactionsRows = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
categoryId: lancamentos.categoriaId,
categoryName: categorias.name,
categoryType: categorias.type,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
inArray(categorias.type, ["despesa", "receita"]),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.orderBy(desc(lancamentos.purchaseDate));
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
const categoriesMap = new Map<string, CategoryOption>();
for (const row of transactionsRows) {
const categoryId = row.categoryId;
if (!categoryId) {
continue;
}
if (!shouldIncludeTransaction(row.name)) {
continue;
}
// Adiciona a categoria ao mapa se ainda não existir
if (!categoriesMap.has(categoryId)) {
categoriesMap.set(categoryId, {
id: categoryId,
name: row.categoryName,
type: row.categoryType,
});
}
const entry: CategoryTransaction = {
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
logo: row.cardLogo ?? row.accountLogo ?? null,
};
if (!transactionsByCategory[categoryId]) {
transactionsByCategory[categoryId] = [];
}
const categoryTransactions = transactionsByCategory[categoryId];
if (categoryTransactions && categoryTransactions.length < 10) {
categoryTransactions.push(entry);
}
}
// Ordena as categorias: receitas primeiro, depois despesas (alfabeticamente dentro de cada tipo)
const categories = Array.from(categoriesMap.values()).sort((a, b) => {
// Receita vem antes de despesa
if (a.type !== b.type) {
return a.type === "receita" ? -1 : 1;
}
// Dentro do mesmo tipo, ordena alfabeticamente
return a.name.localeCompare(b.name);
});
return {
categories,
transactionsByCategory,
};
}

View File

@@ -0,0 +1,71 @@
import { lancamentos, pagadores, cartoes, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { db } from "@/lib/db";
import { eq, and, sql, desc, or, isNull } from "drizzle-orm";
import { toNumber } from "@/lib/dashboard/common";
export type RecentTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
cardLogo: string | null;
accountLogo: string | null;
};
export type RecentTransactionsData = {
transactions: RecentTransaction[];
};
export async function fetchRecentTransactions(
userId: string,
period: string
): Promise<RecentTransactionsData> {
const results = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
cardLogo: cartoes.logo,
accountLogo: contas.logo,
note: lancamentos.note,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt))
.limit(5);
const transactions = results.map((row): RecentTransaction => {
return {
id: row.id,
name: row.name,
amount: Math.abs(toNumber(row.amount)),
purchaseDate: row.purchaseDate,
cardLogo: row.cardLogo,
accountLogo: row.accountLogo,
};
});
return {
transactions,
};
}

View File

@@ -0,0 +1,86 @@
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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, isNull, or, sql } from "drizzle-orm";
export type TopEstablishment = {
id: string;
name: string;
amount: number;
occurrences: number;
logo: string | null;
};
export type TopEstablishmentsData = {
establishments: TopEstablishment[];
};
const shouldIncludeEstablishment = (name: string) => {
const normalized = name.trim().toLowerCase();
if (normalized === "saldo inicial") {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
return true;
};
export async function fetchTopEstablishments(
userId: string,
period: string
): Promise<TopEstablishmentsData> {
const rows = await db
.select({
name: lancamentos.name,
totalAmount: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
occurrences: sql<number>`count(${lancamentos.id})`,
logo: sql<string | null>`max(coalesce(${cartoes.logo}, ${contas.logo}))`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
eq(lancamentos.transactionType, "Despesa"),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
and(
sql`${lancamentos.note} != ${INITIAL_BALANCE_NOTE}`,
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
)
.groupBy(lancamentos.name)
.orderBy(sql`ABS(sum(${lancamentos.amount})) DESC`)
.limit(10);
const establishments = rows
.filter((row) => shouldIncludeEstablishment(row.name))
.map(
(row): TopEstablishment => ({
id: row.name,
name: row.name,
amount: Math.abs(toNumber(row.totalAmount)),
occurrences: Number(row.occurrences ?? 0),
logo: row.logo ?? null,
})
);
return {
establishments,
};
}

View File

@@ -0,0 +1,192 @@
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidget } from "@/components/dashboard/expenses-by-category-widget";
import { IncomeByCategoryWidget } from "@/components/dashboard/income-by-category-widget";
import { IncomeExpenseBalanceWidget } from "@/components/dashboard/income-expense-balance-widget";
import { InstallmentExpensesWidget } from "@/components/dashboard/installment-expenses-widget";
import { InvoicesWidget } from "@/components/dashboard/invoices-widget";
import { MyAccountsWidget } from "@/components/dashboard/my-accounts-widget";
import { PaymentConditionsWidget } from "@/components/dashboard/payment-conditions-widget";
import { PaymentMethodsWidget } from "@/components/dashboard/payment-methods-widget";
import { PaymentStatusWidget } from "@/components/dashboard/payment-status-widget";
import { PurchasesByCategoryWidget } from "@/components/dashboard/purchases-by-category-widget";
import { RecentTransactionsWidget } from "@/components/dashboard/recent-transactions-widget";
import { RecurringExpensesWidget } from "@/components/dashboard/recurring-expenses-widget";
import { TopEstablishmentsWidget } from "@/components/dashboard/top-establishments-widget";
import { TopExpensesWidget } from "@/components/dashboard/top-expenses-widget";
import {
RiArrowUpDoubleLine,
RiBarChartBoxLine,
RiBarcodeLine,
RiBillLine,
RiExchangeLine,
RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine,
RiPieChartLine,
RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line,
RiWallet3Line,
} from "@remixicon/react";
import type { ReactNode } from "react";
import type { DashboardData } from "./fetch-dashboard-data";
export type WidgetConfig = {
id: string;
title: string;
subtitle: string;
icon: ReactNode;
component: (props: { data: DashboardData; period: string }) => ReactNode;
};
export const widgetsConfig: WidgetConfig[] = [
{
id: "my-accounts",
title: "Minhas Contas",
subtitle: "Saldo consolidado disponível",
icon: <RiBarChartBoxLine className="size-4" />,
component: ({ data, period }) => (
<MyAccountsWidget
accounts={data.accountsSnapshot.accounts}
totalBalance={data.accountsSnapshot.totalBalance}
period={period}
/>
),
},
{
id: "invoices",
title: "Faturas",
subtitle: "Resumo das faturas do período",
icon: <RiBillLine className="size-4" />,
component: ({ data }) => (
<InvoicesWidget invoices={data.invoicesSnapshot.invoices} />
),
},
{
id: "boletos",
title: "Boletos",
subtitle: "Controle de boletos do período",
icon: <RiBarcodeLine className="size-4" />,
component: ({ data }) => (
<BoletosWidget boletos={data.boletosSnapshot.boletos} />
),
},
{
id: "payment-status",
title: "Status de Pagamento",
subtitle: "Valores Confirmados E Pendentes",
icon: <RiWallet3Line className="size-4" />,
component: ({ data }) => (
<PaymentStatusWidget data={data.paymentStatusData} />
),
},
{
id: "income-expense-balance",
title: "Receita, Despesa e Balanço",
subtitle: "Últimos 6 Meses",
icon: <RiLineChartLine className="size-4" />,
component: ({ data }) => (
<IncomeExpenseBalanceWidget data={data.incomeExpenseBalanceData} />
),
},
{
id: "recent-transactions",
title: "Lançamentos Recentes",
subtitle: "Últimas 5 despesas registradas",
icon: <RiExchangeLine className="size-4" />,
component: ({ data }) => (
<RecentTransactionsWidget data={data.recentTransactionsData} />
),
},
{
id: "payment-conditions",
title: "Condições de Pagamentos",
subtitle: "Análise das condições",
icon: <RiSlideshowLine className="size-4" />,
component: ({ data }) => (
<PaymentConditionsWidget data={data.paymentConditionsData} />
),
},
{
id: "payment-methods",
title: "Formas de Pagamento",
subtitle: "Distribuição das despesas",
icon: <RiMoneyDollarCircleLine className="size-4" />,
component: ({ data }) => (
<PaymentMethodsWidget data={data.paymentMethodsData} />
),
},
{
id: "recurring-expenses",
title: "Lançamentos Recorrentes",
subtitle: "Despesas recorrentes do período",
icon: <RiRefreshLine className="size-4" />,
component: ({ data }) => (
<RecurringExpensesWidget data={data.recurringExpensesData} />
),
},
{
id: "installment-expenses",
title: "Lançamentos Parcelados",
subtitle: "Acompanhe as parcelas abertas",
icon: <RiNumbersLine className="size-4" />,
component: ({ data }) => (
<InstallmentExpensesWidget data={data.installmentExpensesData} />
),
},
{
id: "top-expenses",
title: "Maiores Gastos do Mês",
subtitle: "Top 10 Despesas",
icon: <RiArrowUpDoubleLine className="size-4" />,
component: ({ data }) => (
<TopExpensesWidget
allExpenses={data.topExpensesAll}
cardOnlyExpenses={data.topExpensesCardOnly}
/>
),
},
{
id: "top-establishments",
title: "Top Estabelecimentos",
subtitle: "Frequência de gastos no período",
icon: <RiStore2Line className="size-4" />,
component: ({ data }) => (
<TopEstablishmentsWidget data={data.topEstablishmentsData} />
),
},
{
id: "purchases-by-category",
title: "Lançamentos por Categorias",
subtitle: "Distribuição de lançamentos por categoria",
icon: <RiStore3Line className="size-4" />,
component: ({ data }) => (
<PurchasesByCategoryWidget data={data.purchasesByCategoryData} />
),
},
{
id: "income-by-category",
title: "Categorias por Receitas",
subtitle: "Distribuição de receitas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<IncomeByCategoryWidget
data={data.incomeByCategoryData}
period={period}
/>
),
},
{
id: "expenses-by-category",
title: "Categorias por Despesas",
subtitle: "Distribuição de despesas por categoria",
icon: <RiPieChartLine className="size-4" />,
component: ({ data, period }) => (
<ExpensesByCategoryWidget
data={data.expensesByCategoryData}
period={period}
/>
),
},
];