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

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,
};
}