forked from git.gladyson/openmonetis
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:
817
app/(dashboard)/insights/actions.ts
Normal file
817
app/(dashboard)/insights/actions.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
cartoes,
|
||||
categorias,
|
||||
contas,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
pagadores,
|
||||
savedInsights,
|
||||
} from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import {
|
||||
InsightsResponseSchema,
|
||||
type InsightsResponse,
|
||||
} from "@/lib/schemas/insights";
|
||||
import { getPreviousPeriod } from "@/lib/utils/period";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { generateObject } from "ai";
|
||||
import { getDay } from "date-fns";
|
||||
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
|
||||
import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data";
|
||||
|
||||
const TRANSFERENCIA = "Transferência";
|
||||
|
||||
type ActionResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Função auxiliar para converter valores numéricos
|
||||
*/
|
||||
const toNumber = (value: unknown): number => {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Agrega dados financeiros do mês para análise
|
||||
*/
|
||||
async function aggregateMonthData(userId: string, period: string) {
|
||||
const previousPeriod = getPreviousPeriod(period);
|
||||
const twoMonthsAgo = getPreviousPeriod(previousPeriod);
|
||||
const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo);
|
||||
|
||||
// Buscar métricas de receitas e despesas dos últimos 3 meses
|
||||
const [currentPeriodRows, previousPeriodRows, twoMonthsAgoRows, threeMonthsAgoRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: 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(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: 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, previousPeriod),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: 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, twoMonthsAgo),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
db
|
||||
.select({
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: 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, threeMonthsAgo),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA),
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
sql`${
|
||||
lancamentos.note
|
||||
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.transactionType),
|
||||
]);
|
||||
|
||||
// Calcular totais dos últimos 3 meses
|
||||
let currentIncome = 0;
|
||||
let currentExpense = 0;
|
||||
let previousIncome = 0;
|
||||
let previousExpense = 0;
|
||||
let twoMonthsAgoIncome = 0;
|
||||
let twoMonthsAgoExpense = 0;
|
||||
let threeMonthsAgoIncome = 0;
|
||||
let threeMonthsAgoExpense = 0;
|
||||
|
||||
for (const row of currentPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === "Receita") currentIncome += amount;
|
||||
else if (row.transactionType === "Despesa") currentExpense += amount;
|
||||
}
|
||||
|
||||
for (const row of previousPeriodRows) {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === "Receita") previousIncome += amount;
|
||||
else if (row.transactionType === "Despesa") previousExpense += amount;
|
||||
}
|
||||
|
||||
for (const row of twoMonthsAgoRows) {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === "Receita") twoMonthsAgoIncome += amount;
|
||||
else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount;
|
||||
}
|
||||
|
||||
for (const row of threeMonthsAgoRows) {
|
||||
const amount = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === "Receita") threeMonthsAgoIncome += amount;
|
||||
else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount;
|
||||
}
|
||||
|
||||
// Buscar despesas por categoria (top 5)
|
||||
const expensesByCategory = await db
|
||||
.select({
|
||||
categoryName: categorias.name,
|
||||
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, 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.name)
|
||||
.orderBy(sql`sum(${lancamentos.amount}) ASC`)
|
||||
.limit(5);
|
||||
|
||||
// Buscar orçamentos e uso
|
||||
const budgetsData = await db
|
||||
.select({
|
||||
categoryName: categorias.name,
|
||||
budgetAmount: orcamentos.amount,
|
||||
spent: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(orcamentos)
|
||||
.innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id))
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.categoriaId, categorias.id),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.transactionType, "Despesa")
|
||||
)
|
||||
)
|
||||
.where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period)))
|
||||
.groupBy(categorias.name, orcamentos.amount);
|
||||
|
||||
// Buscar métricas de cartões
|
||||
const cardsData = await db
|
||||
.select({
|
||||
totalLimit: sql<number>`coalesce(sum(${cartoes.limit}), 0)`,
|
||||
cardCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(cartoes)
|
||||
.where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo")));
|
||||
|
||||
// Buscar saldo total das contas
|
||||
const accountsData = await db
|
||||
.select({
|
||||
totalBalance: sql<number>`coalesce(sum(${contas.initialBalance}), 0)`,
|
||||
accountCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(contas)
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
eq(contas.status, "ativa"),
|
||||
eq(contas.excludeFromBalance, false)
|
||||
)
|
||||
);
|
||||
|
||||
// Calcular ticket médio das transações
|
||||
const avgTicketData = await db
|
||||
.select({
|
||||
avgAmount: sql<number>`coalesce(avg(abs(${lancamentos.amount})), 0)`,
|
||||
transactionCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA)
|
||||
)
|
||||
);
|
||||
|
||||
// Buscar gastos por dia da semana
|
||||
const dayOfWeekSpending = await db
|
||||
.select({
|
||||
purchaseDate: lancamentos.purchaseDate,
|
||||
amount: lancamentos.amount,
|
||||
})
|
||||
.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)
|
||||
)
|
||||
);
|
||||
|
||||
// Agregar por dia da semana
|
||||
const dayTotals = new Map<number, number>();
|
||||
for (const row of dayOfWeekSpending) {
|
||||
if (!row.purchaseDate) continue;
|
||||
const dayOfWeek = getDay(new Date(row.purchaseDate));
|
||||
const current = dayTotals.get(dayOfWeek) ?? 0;
|
||||
dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount)));
|
||||
}
|
||||
|
||||
// Buscar métodos de pagamento (agregado)
|
||||
const paymentMethodsData = await db
|
||||
.select({
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
total: sql<number>`coalesce(sum(abs(${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, PAGADOR_ROLE_ADMIN)
|
||||
)
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod);
|
||||
|
||||
// Buscar transações dos últimos 3 meses para análise de recorrência
|
||||
const last3MonthsTransactions = await db
|
||||
.select({
|
||||
name: lancamentos.name,
|
||||
amount: lancamentos.amount,
|
||||
period: lancamentos.period,
|
||||
condition: lancamentos.condition,
|
||||
installmentCount: lancamentos.installmentCount,
|
||||
currentInstallment: lancamentos.currentInstallment,
|
||||
categoryName: categorias.name,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`,
|
||||
eq(lancamentos.transactionType, "Despesa"),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
ne(lancamentos.transactionType, TRANSFERENCIA)
|
||||
)
|
||||
)
|
||||
.orderBy(lancamentos.name);
|
||||
|
||||
// Análise de recorrência
|
||||
const transactionsByName = new Map<string, Array<{ period: string; amount: number }>>();
|
||||
|
||||
for (const tx of last3MonthsTransactions) {
|
||||
const key = tx.name.toLowerCase().trim();
|
||||
if (!transactionsByName.has(key)) {
|
||||
transactionsByName.set(key, []);
|
||||
}
|
||||
const transactions = transactionsByName.get(key);
|
||||
if (transactions) {
|
||||
transactions.push({
|
||||
period: tx.period,
|
||||
amount: Math.abs(toNumber(tx.amount)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Identificar gastos recorrentes (aparece em 2+ meses com valor similar)
|
||||
const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = [];
|
||||
let totalRecurring = 0;
|
||||
|
||||
for (const [name, occurrences] of transactionsByName.entries()) {
|
||||
if (occurrences.length >= 2) {
|
||||
const amounts = occurrences.map(o => o.amount);
|
||||
const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length;
|
||||
const maxDiff = Math.max(...amounts) - Math.min(...amounts);
|
||||
|
||||
// Considerar recorrente se variação <= 20% da média
|
||||
if (maxDiff <= avgAmount * 0.2) {
|
||||
recurringExpenses.push({
|
||||
name,
|
||||
avgAmount,
|
||||
frequency: occurrences.length,
|
||||
});
|
||||
|
||||
// Somar apenas os do mês atual
|
||||
const currentMonthOccurrence = occurrences.find(o => o.period === period);
|
||||
if (currentMonthOccurrence) {
|
||||
totalRecurring += currentMonthOccurrence.amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Análise de gastos parcelados
|
||||
const installmentTransactions = last3MonthsTransactions.filter(
|
||||
tx => tx.condition === "parcelado" && tx.installmentCount && tx.installmentCount > 1
|
||||
);
|
||||
|
||||
const installmentData = installmentTransactions
|
||||
.filter(tx => tx.period === period)
|
||||
.map(tx => ({
|
||||
name: tx.name,
|
||||
currentInstallment: tx.currentInstallment ?? 1,
|
||||
totalInstallments: tx.installmentCount ?? 1,
|
||||
amount: Math.abs(toNumber(tx.amount)),
|
||||
category: tx.categoryName ?? "Outros",
|
||||
}));
|
||||
|
||||
const totalInstallmentAmount = installmentData.reduce((sum, tx) => sum + tx.amount, 0);
|
||||
const futureCommitment = installmentData.reduce((sum, tx) => {
|
||||
const remaining = (tx.totalInstallments - tx.currentInstallment);
|
||||
return sum + (tx.amount * remaining);
|
||||
}, 0);
|
||||
|
||||
// Montar dados agregados e anonimizados
|
||||
const aggregatedData = {
|
||||
month: period,
|
||||
totalIncome: currentIncome,
|
||||
totalExpense: currentExpense,
|
||||
balance: currentIncome - currentExpense,
|
||||
|
||||
// Tendência de 3 meses
|
||||
threeMonthTrend: {
|
||||
periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period],
|
||||
incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome],
|
||||
expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense],
|
||||
avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4,
|
||||
avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4,
|
||||
trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense
|
||||
? "crescente"
|
||||
: currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense
|
||||
? "decrescente"
|
||||
: "estável",
|
||||
},
|
||||
|
||||
previousMonthIncome: previousIncome,
|
||||
previousMonthExpense: previousExpense,
|
||||
monthOverMonthIncomeChange:
|
||||
Math.abs(previousIncome) > 0.01
|
||||
? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100
|
||||
: 0,
|
||||
monthOverMonthExpenseChange:
|
||||
Math.abs(previousExpense) > 0.01
|
||||
? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100
|
||||
: 0,
|
||||
savingsRate:
|
||||
currentIncome > 0.01
|
||||
? ((currentIncome - currentExpense) / currentIncome) * 100
|
||||
: 0,
|
||||
topExpenseCategories: expensesByCategory.map(
|
||||
(cat: { categoryName: string; total: unknown }) => ({
|
||||
category: cat.categoryName,
|
||||
amount: Math.abs(toNumber(cat.total)),
|
||||
percentageOfTotal:
|
||||
currentExpense > 0
|
||||
? (Math.abs(toNumber(cat.total)) / currentExpense) * 100
|
||||
: 0,
|
||||
})
|
||||
),
|
||||
budgets: budgetsData.map(
|
||||
(b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({
|
||||
category: b.categoryName,
|
||||
budgetAmount: toNumber(b.budgetAmount),
|
||||
spent: Math.abs(toNumber(b.spent)),
|
||||
usagePercentage:
|
||||
toNumber(b.budgetAmount) > 0
|
||||
? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100
|
||||
: 0,
|
||||
})
|
||||
),
|
||||
creditCards: {
|
||||
totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0),
|
||||
cardCount: toNumber(cardsData[0]?.cardCount ?? 0),
|
||||
},
|
||||
accounts: {
|
||||
totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0),
|
||||
accountCount: toNumber(accountsData[0]?.accountCount ?? 0),
|
||||
},
|
||||
avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0),
|
||||
transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0),
|
||||
dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({
|
||||
dayOfWeek:
|
||||
["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A",
|
||||
total,
|
||||
})),
|
||||
paymentMethodsBreakdown: paymentMethodsData.map(
|
||||
(pm: { paymentMethod: string | null; total: unknown }) => ({
|
||||
method: pm.paymentMethod,
|
||||
total: toNumber(pm.total),
|
||||
percentage:
|
||||
currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0,
|
||||
})
|
||||
),
|
||||
|
||||
// Análise de recorrência
|
||||
recurringExpenses: {
|
||||
count: recurringExpenses.length,
|
||||
total: totalRecurring,
|
||||
percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
topRecurring: recurringExpenses
|
||||
.sort((a, b) => b.avgAmount - a.avgAmount)
|
||||
.slice(0, 5)
|
||||
.map(r => ({
|
||||
name: r.name,
|
||||
avgAmount: r.avgAmount,
|
||||
frequency: r.frequency,
|
||||
})),
|
||||
predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0,
|
||||
},
|
||||
|
||||
// Análise de parcelamentos
|
||||
installments: {
|
||||
currentMonthInstallments: installmentData.length,
|
||||
totalInstallmentAmount,
|
||||
percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0,
|
||||
futureCommitment,
|
||||
topInstallments: installmentData
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.slice(0, 5)
|
||||
.map(i => ({
|
||||
name: i.name,
|
||||
current: i.currentInstallment,
|
||||
total: i.totalInstallments,
|
||||
amount: i.amount,
|
||||
category: i.category,
|
||||
remaining: i.totalInstallments - i.currentInstallment,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return aggregatedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera insights usando IA
|
||||
*/
|
||||
export async function generateInsightsAction(
|
||||
period: string,
|
||||
modelId: string
|
||||
): Promise<ActionResult<InsightsResponse>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Validar modelo - verificar se existe na lista ou se é um modelo customizado
|
||||
const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
|
||||
|
||||
// Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido
|
||||
if (!selectedModel && !modelId.includes("/")) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Modelo inválido.",
|
||||
};
|
||||
}
|
||||
|
||||
// Agregar dados
|
||||
const aggregatedData = await aggregateMonthData(user.id, period);
|
||||
|
||||
// Selecionar provider
|
||||
let model;
|
||||
|
||||
// Se o modelo tem "/" é OpenRouter (formato: provider/model)
|
||||
if (modelId.includes("/")) {
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env",
|
||||
};
|
||||
}
|
||||
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey,
|
||||
});
|
||||
model = openrouter.chat(modelId);
|
||||
} else if (selectedModel?.provider === "openai") {
|
||||
model = openai(modelId);
|
||||
} else if (selectedModel?.provider === "anthropic") {
|
||||
model = anthropic(modelId);
|
||||
} else if (selectedModel?.provider === "google") {
|
||||
model = google(modelId);
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: "Provider de modelo não suportado.",
|
||||
};
|
||||
}
|
||||
|
||||
// Chamar AI SDK
|
||||
const result = await generateObject({
|
||||
model,
|
||||
schema: InsightsResponseSchema,
|
||||
system: INSIGHTS_SYSTEM_PROMPT,
|
||||
prompt: `Analise os seguintes dados financeiros agregados do período ${period}.
|
||||
|
||||
Dados agregados:
|
||||
${JSON.stringify(aggregatedData, null, 2)}
|
||||
|
||||
DADOS IMPORTANTES PARA SUA ANÁLISE:
|
||||
|
||||
**Tendência de 3 meses:**
|
||||
- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend)
|
||||
- Use isso para identificar padrões crescentes, decrescentes ou estáveis
|
||||
- Compare o mês atual com a média dos 3 meses
|
||||
|
||||
**Análise de Recorrência:**
|
||||
- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas
|
||||
- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes
|
||||
- Use isso para avaliar previsibilidade e oportunidades de otimização
|
||||
|
||||
**Gastos Parcelados:**
|
||||
- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês
|
||||
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
|
||||
- Use isso para alertas sobre comprometimento de renda futura
|
||||
|
||||
Organize suas observações nas 4 categorias especificadas no prompt do sistema:
|
||||
1. Comportamentos Observados (behaviors): 3-6 itens
|
||||
2. Gatilhos de Consumo (triggers): 3-6 itens
|
||||
3. Recomendações Práticas (recommendations): 3-6 itens
|
||||
4. Melhorias Sugeridas (improvements): 3-6 itens
|
||||
|
||||
Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos.
|
||||
|
||||
Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`,
|
||||
});
|
||||
|
||||
// Validar resposta
|
||||
const validatedData = InsightsResponseSchema.parse(result.object);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error generating insights:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao gerar insights. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva insights gerados no banco de dados
|
||||
*/
|
||||
export async function saveInsightsAction(
|
||||
period: string,
|
||||
modelId: string,
|
||||
data: InsightsResponse
|
||||
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// Verificar se já existe um insight salvo para este período
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Atualizar existente
|
||||
const updated = await db
|
||||
.update(savedInsights)
|
||||
.set({
|
||||
modelId,
|
||||
data: JSON.stringify(data),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(savedInsights.userId, user.id),
|
||||
eq(savedInsights.period, period)
|
||||
)
|
||||
)
|
||||
.returning({ id: savedInsights.id, createdAt: savedInsights.createdAt });
|
||||
|
||||
const updatedRecord = updated[0];
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Falha ao atualizar a análise. Tente novamente.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: updatedRecord.id,
|
||||
createdAt: updatedRecord.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Criar novo
|
||||
const result = await db
|
||||
.insert(savedInsights)
|
||||
.values({
|
||||
userId: user.id,
|
||||
period,
|
||||
modelId,
|
||||
data: JSON.stringify(data),
|
||||
})
|
||||
.returning({ id: savedInsights.id, createdAt: savedInsights.createdAt });
|
||||
|
||||
const insertedRecord = result[0];
|
||||
if (!insertedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Falha ao salvar a análise. Tente novamente.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: insertedRecord.id,
|
||||
createdAt: insertedRecord.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error saving insights:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao salvar análise. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega insights salvos do banco de dados
|
||||
*/
|
||||
export async function loadSavedInsightsAction(
|
||||
period: string
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
insights: InsightsResponse;
|
||||
modelId: string;
|
||||
createdAt: Date;
|
||||
} | null>
|
||||
> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const saved = result[0];
|
||||
if (!saved) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const insights = InsightsResponseSchema.parse(JSON.parse(saved.data));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
insights,
|
||||
modelId: saved.modelId,
|
||||
createdAt: saved.createdAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading saved insights:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao carregar análise salva. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove insights salvos do banco de dados
|
||||
*/
|
||||
export async function deleteSavedInsightsAction(
|
||||
period: string
|
||||
): Promise<ActionResult<void>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
await db
|
||||
.delete(savedInsights)
|
||||
.where(
|
||||
and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period))
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting saved insights:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erro ao remover análise. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
145
app/(dashboard)/insights/data.ts
Normal file
145
app/(dashboard)/insights/data.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tipos de providers disponíveis
|
||||
*/
|
||||
export type AIProvider = "openai" | "anthropic" | "google" | "openrouter";
|
||||
|
||||
/**
|
||||
* Metadados dos providers
|
||||
*/
|
||||
export const PROVIDERS = {
|
||||
openai: {
|
||||
id: "openai" as const,
|
||||
name: "ChatGPT",
|
||||
icon: "RiOpenaiLine",
|
||||
},
|
||||
anthropic: {
|
||||
id: "anthropic" as const,
|
||||
name: "Claude AI",
|
||||
icon: "RiRobot2Line",
|
||||
},
|
||||
google: {
|
||||
id: "google" as const,
|
||||
name: "Gemini",
|
||||
icon: "RiGoogleLine",
|
||||
},
|
||||
openrouter: {
|
||||
id: "openrouter" as const,
|
||||
name: "OpenRouter",
|
||||
icon: "RiRouterLine",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lista de modelos de IA disponíveis para análise de insights
|
||||
*/
|
||||
export const AVAILABLE_MODELS = [
|
||||
// OpenAI Models (5)
|
||||
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" as const },
|
||||
{ id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" as const },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" as const },
|
||||
{ id: "gpt-4o", name: "GPT-4o (Omni)", provider: "openai" as const },
|
||||
|
||||
// Anthropic Models (5)
|
||||
{
|
||||
id: "claude-3.7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4-opus",
|
||||
name: "Claude 4 Opus",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4.5-sonnet",
|
||||
name: "Claude 4.5 Sonnet",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4.5-haiku",
|
||||
name: "Claude 4.5 Haiku",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3.5-sonnet-20240620",
|
||||
name: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
|
||||
// Google Models (5)
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "google" as const,
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
provider: "google" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Modelo padrão
|
||||
*/
|
||||
export const DEFAULT_MODEL = "gpt-5";
|
||||
export const DEFAULT_PROVIDER = "openai";
|
||||
|
||||
/**
|
||||
* System prompt para análise de insights
|
||||
*/
|
||||
export const INSIGHTS_SYSTEM_PROMPT = `Você é um especialista em comportamento financeiro. Analise os dados financeiros fornecidos e organize suas observações em 4 categorias específicas:
|
||||
|
||||
1. **Comportamentos Observados** (behaviors): Padrões de gastos e hábitos financeiros identificados nos dados. Foque em comportamentos recorrentes e tendências. Considere:
|
||||
- Tendência dos últimos 3 meses (crescente, decrescente, estável)
|
||||
- Gastos recorrentes e sua previsibilidade
|
||||
- Padrões de parcelamento e comprometimento futuro
|
||||
|
||||
2. **Gatilhos de Consumo** (triggers): Identifique situações, períodos ou categorias que desencadeiam maiores gastos. O que leva o usuário a gastar mais? Analise:
|
||||
- Dias da semana com mais gastos
|
||||
- Categorias que cresceram nos últimos meses
|
||||
- Métodos de pagamento que facilitam gastos
|
||||
|
||||
3. **Recomendações Práticas** (recommendations): Sugestões concretas e acionáveis para melhorar a saúde financeira. Seja específico e direto. Use os dados de:
|
||||
- Gastos recorrentes que podem ser otimizados
|
||||
- Orçamentos que estão sendo ultrapassados
|
||||
- Comprometimento futuro com parcelamentos
|
||||
|
||||
4. **Melhorias Sugeridas** (improvements): Oportunidades de otimização e estratégias de longo prazo para alcançar objetivos financeiros. Considere:
|
||||
- Tendências preocupantes dos últimos 3 meses
|
||||
- Percentual de gastos recorrentes vs pontuais
|
||||
- Estratégias para reduzir comprometimento futuro
|
||||
|
||||
Para cada categoria, forneça de 3 a 6 itens concisos e objetivos. Use linguagem clara e direta, com verbos de ação. Mantenha privacidade e não exponha dados pessoais sensíveis.
|
||||
|
||||
IMPORTANTE: Utilize os novos dados disponíveis (threeMonthTrend, recurringExpenses, installments) para fornecer insights mais ricos e contextualizados.
|
||||
|
||||
Responda EXCLUSIVAMENTE com um JSON válido seguindo o esquema:
|
||||
{
|
||||
"month": "YYYY-MM",
|
||||
"generatedAt": "ISO datetime",
|
||||
"categories": [
|
||||
{
|
||||
"category": "behaviors",
|
||||
"items": [
|
||||
{ "text": "Observação aqui" },
|
||||
...
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "triggers",
|
||||
"items": [...]
|
||||
},
|
||||
{
|
||||
"category": "recommendations",
|
||||
"items": [...]
|
||||
},
|
||||
{
|
||||
"category": "improvements",
|
||||
"items": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
`;
|
||||
23
app/(dashboard)/insights/layout.tsx
Normal file
23
app/(dashboard)/insights/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSparklingLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Insights | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiSparklingLine />}
|
||||
title="Insights"
|
||||
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
app/(dashboard)/insights/loading.tsx
Normal file
42
app/(dashboard)/insights/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de insights com IA
|
||||
*/
|
||||
export default function InsightsLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-96 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de insights */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="size-8 rounded-full bg-foreground/10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-2/3 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
31
app/(dashboard)/insights/page.tsx
Normal file
31
app/(dashboard)/insights/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { InsightsPage } from "@/components/insights/insights-page";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParam);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
<InsightsPage period={selectedPeriod} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user