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

21
lib/accounts/constants.ts Normal file
View File

@@ -0,0 +1,21 @@
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
export const INITIAL_BALANCE_CATEGORY_NAME = "Saldo inicial";
export const INITIAL_BALANCE_NOTE = "saldo inicial";
export const INITIAL_BALANCE_CONDITION =
LANCAMENTO_CONDITIONS.find((condition) => condition === "À vista") ??
"À vista";
export const INITIAL_BALANCE_PAYMENT_METHOD =
LANCAMENTO_PAYMENT_METHODS.find((method) => method === "Pix") ?? "Pix";
export const INITIAL_BALANCE_TRANSACTION_TYPE =
LANCAMENTO_TRANSACTION_TYPES.find((type) => type === "Receita") ?? "Receita";
export const ACCOUNT_AUTO_INVOICE_NOTE_PREFIX = "AUTO_FATURA:";
export const buildInvoicePaymentNote = (cardId: string, period: string) =>
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`;

110
lib/actions/helpers.ts Normal file
View File

@@ -0,0 +1,110 @@
import { getUser } from "@/lib/auth/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { ActionResult } from "./types";
import { errorResult } from "./types";
/**
* Handles errors in server actions consistently
* @param error - The error to handle
* @returns ActionResult with error message
*/
export function handleActionError(error: unknown): ActionResult {
if (error instanceof z.ZodError) {
return errorResult(error.issues[0]?.message ?? "Dados inválidos.");
}
if (error instanceof Error) {
return errorResult(error.message);
}
return errorResult("Erro inesperado.");
}
/**
* Configuration for revalidation after mutations
*/
export const revalidateConfig = {
cartoes: ["/cartoes"],
contas: ["/contas", "/lancamentos"],
categorias: ["/categorias"],
orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"],
anotacoes: ["/anotacoes"],
lancamentos: ["/lancamentos", "/contas"],
} as const;
/**
* Revalidates paths for a specific entity
* @param entity - The entity type
*/
export function revalidateForEntity(
entity: keyof typeof revalidateConfig
): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path));
}
/**
* Options for action handler
*/
interface ActionHandlerOptions {
/** Paths to revalidate after successful execution */
revalidatePaths?: string[];
/** Entity to revalidate (uses predefined config) */
revalidateEntity?: keyof typeof revalidateConfig;
}
/**
* Creates a standardized action handler with automatic user auth and error handling
*
* @param schema - Zod schema for input validation
* @param handler - Handler function that receives validated data and userId
* @param options - Additional options for the action
* @returns Action function that can be called from client
*
* @example
* ```ts
* export const createItemAction = createActionHandler(
* createItemSchema,
* async (data, userId) => {
* await db.insert(items).values({ ...data, userId });
* return "Item criado com sucesso.";
* },
* { revalidateEntity: 'items' }
* );
* ```
*/
export function createActionHandler<TInput, TResult = string>(
schema: z.ZodSchema<TInput>,
handler: (data: TInput, userId: string) => Promise<TResult>,
options?: ActionHandlerOptions
) {
return async (input: unknown): Promise<ActionResult<TResult>> => {
try {
// Get authenticated user
const user = await getUser();
// Validate input
const data = schema.parse(input);
// Execute handler
const result = await handler(data, user.id);
// Revalidate paths if configured
if (options?.revalidateEntity) {
revalidateForEntity(options.revalidateEntity);
} else if (options?.revalidatePaths) {
options.revalidatePaths.forEach((path) => revalidatePath(path));
}
// Return success with message (if result is string) or data
if (typeof result === "string") {
return { success: true, message: result };
}
return { success: true, message: "Operação realizada com sucesso.", data: result };
} catch (error) {
return handleActionError(error);
}
};
}

27
lib/actions/types.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Common types for server actions
*/
/**
* Standard action result type
*/
export type ActionResult<TData = void> =
| { success: true; message: string; data?: TData }
| { success: false; error: string };
/**
* Success result helper
*/
export function successResult<TData = void>(
message: string,
data?: TData
): ActionResult<TData> {
return { success: true, message, data };
}
/**
* Error result helper
*/
export function errorResult(error: string): ActionResult {
return { success: false, error };
}

15
lib/auth/client.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}),
});
/**
* Indica se o login com Google está habilitado
* Baseado na variável de ambiente NEXT_PUBLIC_GOOGLE_OAUTH_ENABLED
*/
export const googleSignInAvailable = Boolean(
process.env.NEXT_PUBLIC_GOOGLE_OAUTH_ENABLED
);

119
lib/auth/config.ts Normal file
View File

@@ -0,0 +1,119 @@
/**
* Better Auth Configuration
*
* Configuração central de autenticação usando Better Auth.
* Suporta email/password e Google OAuth.
*/
import { seedDefaultCategoriesForUser } from "@/lib/categorias/defaults";
import { db, schema } from "@/lib/db";
import { ensureDefaultPagadorForUser } from "@/lib/pagadores/defaults";
import { normalizeNameFromEmail } from "@/lib/pagadores/utils";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
// ============================================================================
// GOOGLE OAUTH CONFIGURATION
// ============================================================================
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
/**
* Extrai nome do usuário do perfil do Google com fallback hierárquico:
* 1. profile.name (nome completo)
* 2. profile.given_name + profile.family_name
* 3. Nome extraído do email
* 4. "Usuário" (fallback final)
*/
function getNameFromGoogleProfile(profile: GoogleProfile): string {
const fullName = profile.name?.trim();
if (fullName) return fullName;
const fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
return fromEmail ?? "Usuário";
}
// ============================================================================
// BETTER AUTH INSTANCE
// ============================================================================
export const auth = betterAuth({
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Google OAuth (se configurado)
socialProviders:
googleClientId && googleClientSecret
? {
google: {
clientId: googleClientId,
clientSecret: googleClientSecret,
mapProfileToUser: (profile) => ({
name: getNameFromGoogleProfile(profile),
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified,
}),
},
}
: undefined,
// Database hooks - Executados após eventos do DB
databaseHooks: {
user: {
create: {
/**
* Após criar novo usuário, inicializa:
* 1. Categorias padrão (Receitas/Despesas)
* 2. Pagador padrão (vinculado ao usuário)
*/
after: async (user) => {
// Se falhar aqui, o usuário já foi criado - considere usar queue para retry
try {
await seedDefaultCategoriesForUser(user.id);
await ensureDefaultPagadorForUser({
id: user.id,
name: user.name ?? undefined,
email: user.email ?? undefined,
image: user.image ?? undefined,
});
} catch (error) {
console.error(
"[Auth] Falha ao criar dados padrão do usuário:",
error
);
// TODO: Considere enfileirar para retry ou notificar admin
}
},
},
},
},
});
// Aviso em desenvolvimento se Google OAuth não estiver configurado
if (!googleClientId && process.env.NODE_ENV === "development") {
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET."
);
}

69
lib/auth/server.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Server-side authentication utilities
*
* This module consolidates server-side auth functions from:
* - /lib/get-user.tsx
* - /lib/get-user-id.tsx
* - /lib/get-user-session.tsx
*
* All functions in this module are server-side only and will redirect
* to /login if the user is not authenticated.
*/
import { auth } from "@/lib/auth/config";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
/**
* Gets the current authenticated user
* @returns User object
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
return session.user;
}
/**
* Gets the current authenticated user ID
* @returns User ID string
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
return session.user.id;
}
/**
* Gets the current authenticated session
* @returns Full session object including user
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
return session;
}
/**
* Gets the current session without requiring authentication
* @returns Session object or null if not authenticated
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() });
}

View File

@@ -0,0 +1,14 @@
/**
* Category constants and types
*
* Consolidated from /lib/categories.ts
*/
export const CATEGORY_TYPES = ["receita", "despesa"] as const;
export type CategoryType = (typeof CATEGORY_TYPES)[number];
export const CATEGORY_TYPE_LABEL: Record<CategoryType, string> = {
receita: "Receita",
despesa: "Despesa",
};

View File

@@ -0,0 +1,90 @@
/**
* Category defaults and seeding
*
* Consolidated from:
* - /lib/category-defaults.ts
*/
import { categorias } from "@/db/schema";
import type { CategoryType } from "@/lib/categorias/constants";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
export type DefaultCategory = {
name: string;
type: CategoryType;
icon: string | null;
};
export const DEFAULT_CATEGORIES: DefaultCategory[] = [
// Despesas
{ name: "Alimentação", type: "despesa", icon: "RiRestaurant2Line" },
{ name: "Transporte", type: "despesa", icon: "RiBusLine" },
{ name: "Moradia", type: "despesa", icon: "RiHomeLine" },
{ name: "Saúde", type: "despesa", icon: "RiStethoscopeLine" },
{ name: "Educação", type: "despesa", icon: "RiBook2Line" },
{ name: "Lazer", type: "despesa", icon: "RiGamepadLine" },
{ name: "Compras", type: "despesa", icon: "RiShoppingBagLine" },
{ name: "Assinaturas", type: "despesa", icon: "RiServiceLine" },
{ name: "Pets", type: "despesa", icon: "RiBearSmileLine" },
{ name: "Mercado", type: "despesa", icon: "RiShoppingBasketLine" },
{ name: "Restaurantes", type: "despesa", icon: "RiRestaurantLine" },
{ name: "Delivery", type: "despesa", icon: "RiMotorbikeLine" },
{ name: "Energia e água", type: "despesa", icon: "RiFlashlightLine" },
{ name: "Internet", type: "despesa", icon: "RiWifiLine" },
{ name: "Vestuário", type: "despesa", icon: "RiTShirtLine" },
{ name: "Viagem", type: "despesa", icon: "RiFlightTakeoffLine" },
{ name: "Presentes", type: "despesa", icon: "RiGiftLine" },
{ name: "Pagamentos", type: "despesa", icon: "RiBillLine" },
{ name: "Outras despesas", type: "despesa", icon: "RiMore2Line" },
// Receitas
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
{ name: "Reembolso", type: "receita", icon: "RiRefundLine" },
{ name: "Aluguel recebido", type: "receita", icon: "RiBuilding2Line" },
{ name: "Outras receitas", type: "receita", icon: "RiMore2Line" },
{ name: "Saldo inicial", type: "receita", icon: "RiWallet2Line" },
// Categoria especial para transferências entre contas
{
name: "Transferência interna",
type: "receita",
icon: "RiArrowLeftRightLine",
},
];
/**
* Seeds default categories for a new user
* @param userId - User ID to seed categories for
*/
export async function seedDefaultCategoriesForUser(userId: string | undefined) {
if (!userId) {
return;
}
const existing = await db.query.categorias.findFirst({
columns: { id: true },
where: eq(categorias.userId, userId),
});
if (existing) {
return;
}
if (DEFAULT_CATEGORIES.length === 0) {
return;
}
await db.insert(categorias).values(
DEFAULT_CATEGORIES.map((category) => ({
name: category.name,
type: category.type,
icon: category.icon,
userId,
}))
);
}

172
lib/categorias/icons.ts Normal file
View File

@@ -0,0 +1,172 @@
/**
* Category icon options and utilities
*
* Consolidated from:
* - /lib/category-icons.ts
*/
export type CategoryIconOption = {
label: string;
value: string;
};
export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
// Finanças
{ label: "Dinheiro", value: "RiMoneyDollarCircleLine" },
{ label: "Carteira", value: "RiWallet3Line" },
{ label: "Carteira 2", value: "RiWalletLine" },
{ label: "Cartão", value: "RiBankCardLine" },
{ label: "Banco", value: "RiBankLine" },
{ label: "Moedas", value: "RiHandCoinLine" },
{ label: "Gráfico", value: "RiLineChartLine" },
{ label: "Ações", value: "RiStockLine" },
{ label: "Troca", value: "RiExchangeLine" },
{ label: "Reembolso", value: "RiRefundLine" },
{ label: "Recompensa", value: "RiRefund2Line" },
{ label: "Leilão", value: "RiAuctionLine" },
// Compras
{ label: "Carrinho", value: "RiShoppingCartLine" },
{ label: "Sacola", value: "RiShoppingBagLine" },
{ label: "Cesta", value: "RiShoppingBasketLine" },
{ label: "Presente", value: "RiGiftLine" },
{ label: "Cupom", value: "RiCouponLine" },
{ label: "Ticket", value: "RiTicket2Line" },
// Alimentação
{ label: "Restaurante", value: "RiRestaurantLine" },
{ label: "Garfo e faca", value: "RiRestaurant2Line" },
{ label: "Café", value: "RiCupLine" },
{ label: "Bebida", value: "RiDrinksFill" },
{ label: "Pizza", value: "RiCake3Line" },
// Transporte
{ label: "Ônibus", value: "RiBusLine" },
{ label: "Carro", value: "RiCarLine" },
{ label: "Táxi", value: "RiTaxiLine" },
{ label: "Moto", value: "RiMotorbikeLine" },
{ label: "Avião", value: "RiFlightTakeoffLine" },
{ label: "Navio", value: "RiShipLine" },
{ label: "Trem", value: "RiTrainLine" },
{ label: "Metrô", value: "RiSubwayLine" },
{ label: "Bicicleta", value: "RiBikeLine" },
{ label: "Mapa", value: "RiMapPinLine" },
{ label: "Combustível", value: "RiGasStationLine" },
// Moradia
{ label: "Casa", value: "RiHomeLine" },
{ label: "Prédio", value: "RiBuilding2Line" },
{ label: "Apartamento", value: "RiBuildingLine" },
{ label: "Ferramentas", value: "RiToolsLine" },
{ label: "Lâmpada", value: "RiLightbulbLine" },
{ label: "Energia", value: "RiFlashlightLine" },
// Saúde e bem-estar
{ label: "Saúde", value: "RiStethoscopeLine" },
{ label: "Hospital", value: "RiHospitalLine" },
{ label: "Coração", value: "RiHeart2Line" },
{ label: "Pulso", value: "RiHeartPulseLine" },
{ label: "Mental", value: "RiMentalHealthLine" },
{ label: "Farmácia", value: "RiFirstAidKitLine" },
{ label: "Fitness", value: "RiRunLine" },
// Educação
{ label: "Livro", value: "RiBook2Line" },
{ label: "Graduação", value: "RiGraduationCapLine" },
{ label: "Escola", value: "RiSchoolLine" },
{ label: "Lápis", value: "RiPencilLine" },
// Trabalho
{ label: "Maleta", value: "RiBriefcaseLine" },
{ label: "Pasta", value: "RiBriefcase4Line" },
{ label: "Escritório", value: "RiUserStarLine" },
// Lazer
{ label: "Controle", value: "RiGamepadLine" },
{ label: "Filme", value: "RiMovie2Line" },
{ label: "Música", value: "RiMusic2Line" },
{ label: "Microfone", value: "RiMicLine" },
{ label: "Fone", value: "RiHeadphoneLine" },
{ label: "Câmera", value: "RiCameraLine" },
{ label: "Praia", value: "RiUmbrellaLine" },
{ label: "Futebol", value: "RiFootballLine" },
{ label: "Basquete", value: "RiBasketballLine" },
// Tecnologia
{ label: "WiFi", value: "RiWifiLine" },
{ label: "Celular", value: "RiSmartphoneLine" },
{ label: "Computador", value: "RiComputerLine" },
{ label: "Monitor", value: "RiMonitorLine" },
{ label: "Teclado", value: "RiKeyboardLine" },
{ label: "Mouse", value: "RiMouseLine" },
{ label: "Fone Bluetooth", value: "RiBluetoothLine" },
// Pessoas
{ label: "Usuário", value: "RiUserLine" },
{ label: "Grupo", value: "RiGroupLine" },
{ label: "Família", value: "RiParentLine" },
{ label: "Bebê", value: "RiBabyCarriageLine" },
// Animais
{ label: "Pet", value: "RiBearSmileLine" },
// Vestuário
{ label: "Camiseta", value: "RiTShirtLine" },
// Documentos
{ label: "Arquivo", value: "RiFileTextLine" },
{ label: "Documento", value: "RiArticleLine" },
{ label: "Balança", value: "RiScales2Line" },
{ label: "Escudo", value: "RiShieldCheckLine" },
// Serviços
{ label: "Serviço", value: "RiServiceLine" },
{ label: "Alerta", value: "RiAlertLine" },
{ label: "Troféu", value: "RiMedalLine" },
// Outros
{ label: "Mais", value: "RiMore2Line" },
{ label: "Estrela", value: "RiStarLine" },
{ label: "Foguete", value: "RiRocketLine" },
{ label: "Ampulheta", value: "RiHourglassLine" },
{ label: "Calendário", value: "RiCalendarLine" },
{ label: "Relógio", value: "RiTimeLine" },
{ label: "Timer", value: "RiTimer2Line" },
{ label: "Fogo", value: "RiFireLine" },
{ label: "Gota", value: "RiDropLine" },
{ label: "Sol", value: "RiSunLine" },
{ label: "Lua", value: "RiMoonLine" },
{ label: "Nuvem", value: "RiCloudLine" },
{ label: "Raio", value: "RiFlashlightFill" },
{ label: "Planta", value: "RiPlantLine" },
{ label: "Árvore", value: "RiSeedlingLine" },
{ label: "Globo", value: "RiGlobalLine" },
{ label: "Localização", value: "RiMapPin2Line" },
{ label: "Bússola", value: "RiCompassLine" },
{ label: "Reciclagem", value: "RiRecycleLine" },
{ label: "Cadeado", value: "RiLockLine" },
{ label: "Chave", value: "RiKeyLine" },
{ label: "Configurações", value: "RiSettings3Line" },
{ label: "Link", value: "RiLinkLine" },
{ label: "Anexo", value: "RiAttachmentLine" },
{ label: "Download", value: "RiDownloadLine" },
{ label: "Upload", value: "RiUploadLine" },
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
{ label: "Nuvem Upload", value: "RiCloudUploadLine" },
];
/**
* Gets all available category icon options
* @returns Array of icon options
*/
export function getCategoryIconOptions() {
return CATEGORY_ICON_OPTIONS;
}
/**
* Gets the default icon for a category type
* @returns Default icon value
*/
export function getDefaultIconForType() {
return CATEGORY_ICON_OPTIONS[0]?.value ?? "";
}

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}
/>
),
},
];

39
lib/db.ts Normal file
View File

@@ -0,0 +1,39 @@
import * as schema from "@/db/schema";
import { drizzle, type PgDatabase } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const globalForDb = globalThis as unknown as {
db?: PgDatabase<typeof schema>;
pool?: Pool;
};
let _db: PgDatabase<typeof schema> | undefined;
let _pool: Pool | undefined;
function getDb() {
if (_db) return _db;
const { DATABASE_URL } = process.env;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL env variable is not set");
}
_pool = globalForDb.pool ?? new Pool({ connectionString: DATABASE_URL });
_db = globalForDb.db ?? drizzle(_pool, { schema });
if (process.env.NODE_ENV !== "production") {
globalForDb.pool = _pool;
globalForDb.db = _db;
}
return _db;
}
export const db = new Proxy({} as PgDatabase<typeof schema>, {
get(_, prop) {
return Reflect.get(getDb(), prop);
},
});
export { schema };

32
lib/faturas.ts Normal file
View File

@@ -0,0 +1,32 @@
export const INVOICE_PAYMENT_STATUS = {
PENDING: "pendente",
PAID: "pago",
} as const;
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
export type InvoicePaymentStatus =
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
};
export const INVOICE_STATUS_BADGE_VARIANT: Record<
InvoicePaymentStatus,
"default" | "secondary" | "success" | "info"
> = {
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
[INVOICE_PAYMENT_STATUS.PAID]: "success",
};
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
{
[INVOICE_PAYMENT_STATUS.PENDING]:
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
[INVOICE_PAYMENT_STATUS.PAID]:
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
};
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;

View File

@@ -0,0 +1,143 @@
import type { Lancamento } from "@/db/schema";
import type { EligibleInstallment } from "./anticipation-types";
/**
* Calcula o valor total de antecipação baseado nas parcelas selecionadas
*/
export function calculateTotalAnticipationAmount(
installments: EligibleInstallment[]
): number {
return installments.reduce((sum, inst) => sum + Number(inst.amount), 0);
}
/**
* Valida se o período de antecipação é válido
* O período não pode ser anterior ao período da primeira parcela selecionada
*/
export function validateAnticipationPeriod(
period: string,
installments: EligibleInstallment[]
): boolean {
if (installments.length === 0) return false;
const earliestPeriod = installments.reduce((earliest, inst) => {
return inst.period < earliest ? inst.period : earliest;
}, installments[0].period);
return period >= earliestPeriod;
}
/**
* Formata os números das parcelas antecipadas em uma string legível
* Exemplo: "1, 2, 3" ou "5, 6, 7, 8"
*/
export function getAnticipatedInstallmentNumbers(
installments: EligibleInstallment[]
): string {
const numbers = installments
.map((inst) => inst.currentInstallment)
.filter((num): num is number => num !== null)
.sort((a, b) => a - b)
.join(", ");
return numbers;
}
/**
* Formata o resumo de parcelas antecipadas
* Exemplo: "Parcelas 1-3 de 12" ou "Parcela 5 de 12"
*/
export function formatAnticipatedInstallmentsRange(
installments: EligibleInstallment[]
): string {
const numbers = installments
.map((inst) => inst.currentInstallment)
.filter((num): num is number => num !== null)
.sort((a, b) => a - b);
if (numbers.length === 0) return "";
if (numbers.length === 1) {
const total = installments[0]?.installmentCount ?? 0;
return `Parcela ${numbers[0]} de ${total}`;
}
const first = numbers[0];
const last = numbers[numbers.length - 1];
const total = installments[0]?.installmentCount ?? 0;
// Se as parcelas são consecutivas
const isConsecutive = numbers.every((num, i) => {
if (i === 0) return true;
return num === numbers[i - 1]! + 1;
});
if (isConsecutive) {
return `Parcelas ${first}-${last} de ${total}`;
} else {
return `${numbers.length} parcelas de ${total}`;
}
}
/**
* Verifica se uma antecipação pode ser cancelada
* Só pode cancelar se o lançamento de antecipação não foi pago
*/
export function canCancelAnticipation(lancamento: Lancamento): boolean {
return lancamento.isSettled !== true;
}
/**
* Ordena parcelas por número da parcela atual
*/
export function sortInstallmentsByNumber(
installments: EligibleInstallment[]
): EligibleInstallment[] {
return [...installments].sort((a, b) => {
const aNum = a.currentInstallment ?? 0;
const bNum = b.currentInstallment ?? 0;
return aNum - bNum;
});
}
/**
* Calcula quantas parcelas restam após uma antecipação
*/
export function calculateRemainingInstallments(
totalInstallments: number,
anticipatedCount: number
): number {
return Math.max(0, totalInstallments - anticipatedCount);
}
/**
* Valida se as parcelas selecionadas pertencem à mesma série
*/
export function validateInstallmentsSameSeries(
installments: EligibleInstallment[],
seriesId: string
): boolean {
// Esta validação será feita no servidor com os dados completos
// Aqui apenas retorna true como placeholder
return installments.length > 0;
}
/**
* Gera descrição automática para o lançamento de antecipação
*/
export function generateAnticipationDescription(
lancamentoName: string,
installmentCount: number
): string {
return `Antecipação de ${installmentCount} ${
installmentCount === 1 ? "parcela" : "parcelas"
} - ${lancamentoName}`;
}
/**
* Formata nota automática para antecipação
*/
export function generateAnticipationNote(
installments: EligibleInstallment[]
): string {
const range = formatAnticipatedInstallmentsRange(installments);
return `Antecipação: ${range}`;
}

View File

@@ -0,0 +1,66 @@
import type {
Categoria,
InstallmentAnticipation,
Lancamento,
Pagador,
} from "@/db/schema";
/**
* Parcela elegível para antecipação
*/
export type EligibleInstallment = {
id: string;
name: string;
amount: string;
period: string;
purchaseDate: Date;
dueDate: Date | null;
currentInstallment: number | null;
installmentCount: number | null;
paymentMethod: string;
categoriaId: string | null;
pagadorId: string | null;
};
/**
* Antecipação com dados completos
*/
export type InstallmentAnticipationWithRelations = InstallmentAnticipation & {
lancamento: Lancamento;
pagador: Pagador | null;
categoria: Categoria | null;
};
/**
* Input para criar antecipação
*/
export type CreateAnticipationInput = {
seriesId: string;
installmentIds: string[];
anticipationPeriod: string;
pagadorId?: string;
categoriaId?: string;
note?: string;
};
/**
* Input para cancelar antecipação
*/
export type CancelAnticipationInput = {
anticipationId: string;
};
/**
* Resumo de antecipação para exibição
*/
export type AnticipationSummary = {
id: string;
totalAmount: string;
installmentCount: number;
anticipationPeriod: string;
anticipationDate: Date;
note: string | null;
isSettled: boolean;
lancamentoId: string;
anticipatedInstallments: string[];
};

79
lib/installments/utils.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Calcula a data da última parcela baseado no período da parcela atual
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
* @param currentInstallment - Número da parcela atual
* @param totalInstallments - Quantidade total de parcelas
* @returns Data da última parcela
*/
export function calculateLastInstallmentDate(
currentPeriod: string,
currentInstallment: number,
totalInstallments: number
): Date {
// Parse do período atual (formato: "YYYY-MM")
const [yearStr, monthStr] = currentPeriod.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1; // 0-indexed
if (Number.isNaN(year) || Number.isNaN(monthIndex)) {
return new Date();
}
// Cria data do período atual (parcela atual)
const currentDate = new Date(year, monthIndex, 1);
// Calcula quantas parcelas faltam (incluindo a atual)
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
const remainingInstallments = totalInstallments - currentInstallment + 1;
// Calcula quantos meses adicionar para chegar na última parcela
// Ex: restam 5 parcelas -> adicionar 4 meses (parcela atual + 4 = 5 parcelas)
const monthsToAdd = remainingInstallments - 1;
// Simplificando: monthsToAdd = totalInstallments - currentInstallment
currentDate.setMonth(currentDate.getMonth() + monthsToAdd);
return currentDate;
}
/**
* Formata a data da última parcela no formato "Mês de Ano"
* Exemplo: "Março de 2026"
*/
export function formatLastInstallmentDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
timeZone: "UTC",
});
const formatted = formatter.format(date);
// Capitaliza a primeira letra
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
/**
* Formata a data de compra no formato "dia, dd mmm"
* Exemplo: "qua, 24 set"
*/
export function formatPurchaseDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
return formatter.format(date);
}
/**
* Formata o texto da parcela atual
* Exemplo: "1 de 6"
*/
export function formatCurrentInstallment(
current: number,
total: number
): string {
return `${current} de ${total}`;
}

View File

@@ -0,0 +1,83 @@
/**
* Categoria grouping and sorting helpers for lancamentos
*/
import type { SelectOption } from "@/components/lancamentos/types";
/**
* Capitalizes the first letter of a string
*/
function capitalize(value: string): string {
return value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
}
/**
* Group label for categorias
*/
type CategoriaGroup = {
label: string;
options: SelectOption[];
};
/**
* Normalizes category group labels (Despesa -> Despesas, Receita -> Receitas)
*/
function normalizeCategoryGroupLabel(value: string): string {
const lower = value.toLowerCase();
if (lower === "despesa") {
return "Despesas";
}
if (lower === "receita") {
return "Receitas";
}
return capitalize(value);
}
/**
* Groups and sorts categoria options by their group property
* @param categoriaOptions - Array of categoria select options
* @returns Array of grouped and sorted categoria options
*/
export function groupAndSortCategorias(
categoriaOptions: SelectOption[]
): CategoriaGroup[] {
// Group categorias by their group property
const groups = categoriaOptions.reduce<Record<string, SelectOption[]>>(
(acc, option) => {
const key = option.group ?? "Outros";
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(option);
return acc;
},
{}
);
// Define preferred order (Despesa first, then Receita, then others)
const preferredOrder = ["Despesa", "Receita"];
const orderedKeys = [
...preferredOrder.filter((key) => groups[key]?.length),
...Object.keys(groups).filter((key) => !preferredOrder.includes(key)),
];
// Map to final structure with normalized labels and sorted options
return orderedKeys.map((key) => ({
label: normalizeCategoryGroupLabel(key),
options: groups[key]
.slice()
.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" })
),
}));
}
/**
* Filters secondary pagador options to exclude the primary pagador
*/
export function filterSecondaryPagadorOptions(
allOptions: SelectOption[],
primaryPagadorId?: string
): SelectOption[] {
return allOptions.filter((option) => option.value !== primaryPagadorId);
}

View File

@@ -0,0 +1,15 @@
export const LANCAMENTO_TRANSACTION_TYPES = ["Despesa", "Receita", "Transferência"] as const;
export const LANCAMENTO_CONDITIONS = [
"À vista",
"Parcelado",
"Recorrente",
] as const;
export const LANCAMENTO_PAYMENT_METHODS = [
"Cartão de crédito",
"Cartão de débito",
"Pix",
"Dinheiro",
"Boleto",
] as const;

View File

@@ -0,0 +1,192 @@
/**
* Form state management helpers for lancamentos
*/
import type { LancamentoItem } from "@/components/lancamentos/types";
import { LANCAMENTO_CONDITIONS, LANCAMENTO_PAYMENT_METHODS, LANCAMENTO_TRANSACTION_TYPES } from "./constants";
import { derivePeriodFromDate } from "@/lib/utils/period";
import { getTodayDateString } from "@/lib/utils/date";
/**
* Form state type for lancamento dialog
*/
export type LancamentoFormState = {
purchaseDate: string;
period: string;
name: string;
transactionType: string;
amount: string;
condition: string;
paymentMethod: string;
pagadorId: string | undefined;
secondaryPagadorId: string | undefined;
isSplit: boolean;
contaId: string | undefined;
cartaoId: string | undefined;
categoriaId: string | undefined;
installmentCount: string;
recurrenceCount: string;
dueDate: string;
boletoPaymentDate: string;
note: string;
isSettled: boolean | null;
};
/**
* Initial state overrides for lancamento form
*/
export type LancamentoFormOverrides = {
defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;
};
/**
* Builds initial form state from lancamento data and defaults
*/
export function buildLancamentoInitialState(
lancamento?: LancamentoItem,
defaultPagadorId?: string | null,
preferredPeriod?: string,
overrides?: LancamentoFormOverrides
): LancamentoFormState {
const purchaseDate = lancamento?.purchaseDate
? lancamento.purchaseDate.slice(0, 10)
: overrides?.defaultPurchaseDate ?? "";
const paymentMethod =
lancamento?.paymentMethod ??
overrides?.defaultPaymentMethod ??
LANCAMENTO_PAYMENT_METHODS[0];
const derivedPeriod = derivePeriodFromDate(purchaseDate);
const fallbackPeriod =
preferredPeriod && /^\d{4}-\d{2}$/.test(preferredPeriod)
? preferredPeriod
: derivedPeriod;
const fallbackPagadorId = lancamento?.pagadorId ?? defaultPagadorId ?? null;
const boletoPaymentDate =
lancamento?.boletoPaymentDate ??
(paymentMethod === "Boleto" && (lancamento?.isSettled ?? false)
? getTodayDateString()
: "");
return {
purchaseDate,
period:
lancamento?.period && /^\d{4}-\d{2}$/.test(lancamento.period)
? lancamento.period
: fallbackPeriod,
name: lancamento?.name ?? "",
transactionType:
lancamento?.transactionType ?? LANCAMENTO_TRANSACTION_TYPES[0],
amount:
typeof lancamento?.amount === "number"
? (Math.round(Math.abs(lancamento.amount) * 100) / 100).toFixed(2)
: "",
condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0],
paymentMethod,
pagadorId: fallbackPagadorId ?? undefined,
secondaryPagadorId: undefined,
isSplit: false,
contaId:
paymentMethod === "Cartão de crédito"
? undefined
: lancamento?.contaId ?? undefined,
cartaoId:
paymentMethod === "Cartão de crédito"
? lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined
: undefined,
categoriaId: lancamento?.categoriaId ?? undefined,
installmentCount: lancamento?.installmentCount
? String(lancamento.installmentCount)
: "",
recurrenceCount: lancamento?.recurrenceCount
? String(lancamento.recurrenceCount)
: "",
dueDate: lancamento?.dueDate ?? "",
boletoPaymentDate,
note: lancamento?.note ?? "",
isSettled:
paymentMethod === "Cartão de crédito"
? null
: lancamento?.isSettled ?? false,
};
}
/**
* Applies field dependencies when form state changes
* This function encapsulates the business logic for field interdependencies
*/
export function applyFieldDependencies(
key: keyof LancamentoFormState,
value: LancamentoFormState[keyof LancamentoFormState],
currentState: LancamentoFormState,
_periodDirty: boolean
): Partial<LancamentoFormState> {
const updates: Partial<LancamentoFormState> = {};
// Removed automatic period update when purchase date changes
// if (key === "purchaseDate" && typeof value === "string") {
// if (!periodDirty) {
// updates.period = derivePeriodFromDate(value);
// }
// }
// When condition changes, clear irrelevant fields
if (key === "condition" && typeof value === "string") {
if (value !== "Parcelado") {
updates.installmentCount = "";
}
if (value !== "Recorrente") {
updates.recurrenceCount = "";
}
}
// When payment method changes, adjust related fields
if (key === "paymentMethod" && typeof value === "string") {
if (value === "Cartão de crédito") {
updates.contaId = undefined;
updates.isSettled = null;
} else {
updates.cartaoId = undefined;
updates.isSettled = currentState.isSettled ?? false;
}
// Clear boleto-specific fields if not boleto
if (value !== "Boleto") {
updates.dueDate = "";
updates.boletoPaymentDate = "";
} else if (currentState.isSettled || (updates.isSettled !== null && updates.isSettled !== undefined)) {
// Set today's date for boleto payment if settled
const settled = updates.isSettled ?? currentState.isSettled;
if (settled) {
updates.boletoPaymentDate = currentState.boletoPaymentDate || getTodayDateString();
}
}
}
// When split is disabled, clear secondary pagador
if (key === "isSplit" && value === false) {
updates.secondaryPagadorId = undefined;
}
// When primary pagador changes, clear secondary if it matches
if (key === "pagadorId" && typeof value === "string") {
const secondaryValue = currentState.secondaryPagadorId;
if (secondaryValue && secondaryValue === value) {
updates.secondaryPagadorId = undefined;
}
}
// When isSettled changes and payment method is Boleto
if (key === "isSettled" && currentState.paymentMethod === "Boleto") {
if (value === true) {
updates.boletoPaymentDate = currentState.boletoPaymentDate || getTodayDateString();
} else if (value === false) {
updates.boletoPaymentDate = "";
}
}
return updates;
}

View File

@@ -0,0 +1,94 @@
/**
* Formatting helpers for displaying lancamento data
*/
/**
* Capitalizes the first letter of a string
*/
function capitalize(value: string): string {
return value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
}
/**
* Currency formatter for pt-BR locale (BRL)
*/
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
});
/**
* Date formatter for pt-BR locale (dd/mm/yyyy)
*/
export const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
/**
* Month formatter for pt-BR locale (Month Year)
*/
export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
month: "long",
year: "numeric",
});
/**
* Formats a date string to localized format
* @param value - ISO date string or null
* @returns Formatted date string or "—"
* @example formatDate("2024-01-15") => "15/01/2024"
*/
export function formatDate(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return dateFormatter.format(date);
}
/**
* Formats a period (YYYY-MM) to localized month label
* @param value - Period string (YYYY-MM) or null
* @returns Formatted period string or "—"
* @example formatPeriod("2024-01") => "Janeiro 2024"
*/
export function formatPeriod(value?: string | null): string {
if (!value) return "—";
const [year, month] = value.split("-").map(Number);
if (!year || !month) return value;
const date = new Date(year, month - 1, 1);
return capitalize(monthFormatter.format(date));
}
/**
* Formats a condition string with proper capitalization
* @param value - Condition string or null
* @returns Formatted condition string or "—"
* @example formatCondition("vista") => "À vista"
*/
export function formatCondition(value?: string | null): string {
if (!value) return "—";
if (value.toLowerCase() === "vista") return "À vista";
return capitalize(value);
}
/**
* Gets the badge variant for a transaction type
* @param type - Transaction type (Receita/Despesa)
* @returns Badge variant
*/
export function getTransactionBadgeVariant(type?: string | null): "default" | "destructive" | "secondary" {
if (!type) return "secondary";
return type.toLowerCase() === "receita" ? "default" : "destructive";
}
/**
* Formats currency value
* @param value - Numeric value
* @returns Formatted currency string
* @example formatCurrency(1234.56) => "R$ 1.234,56"
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
}

View File

@@ -0,0 +1,521 @@
import type { SelectOption } from "@/components/lancamentos/types";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "@/lib/lancamentos/constants";
import { PAGADOR_ROLE_ADMIN, PAGADOR_ROLE_TERCEIRO } from "@/lib/pagadores/constants";
import type { SQL } from "drizzle-orm";
import { eq, ilike, or } from "drizzle-orm";
type PagadorRow = typeof pagadores.$inferSelect;
type ContaRow = typeof contas.$inferSelect;
type CartaoRow = typeof cartoes.$inferSelect;
type CategoriaRow = typeof categorias.$inferSelect;
export type ResolvedSearchParams =
| Record<string, string | string[] | undefined>
| undefined;
export type LancamentoSearchFilters = {
transactionFilter: string | null;
conditionFilter: string | null;
paymentFilter: string | null;
pagadorFilter: string | null;
categoriaFilter: string | null;
contaCartaoFilter: string | null;
searchFilter: string | null;
};
type BaseSluggedOption = {
id: string;
label: string;
slug: string;
};
type PagadorSluggedOption = BaseSluggedOption & {
role: string | null;
avatarUrl: string | null;
};
type CategoriaSluggedOption = BaseSluggedOption & {
type: string | null;
icon: string | null;
};
type ContaSluggedOption = BaseSluggedOption & {
kind: "conta";
logo: string | null;
};
type CartaoSluggedOption = BaseSluggedOption & {
kind: "cartao";
logo: string | null;
};
export type SluggedFilters = {
pagadorFiltersRaw: PagadorSluggedOption[];
categoriaFiltersRaw: CategoriaSluggedOption[];
contaFiltersRaw: ContaSluggedOption[];
cartaoFiltersRaw: CartaoSluggedOption[];
};
export type SlugMaps = {
pagador: Map<string, string>;
categoria: Map<string, string>;
conta: Map<string, string>;
cartao: Map<string, string>;
};
export type FilterOption = {
slug: string;
label: string;
};
export type ContaCartaoFilterOption = FilterOption & {
kind: "conta" | "cartao";
};
export type LancamentoOptionSets = {
pagadorOptions: SelectOption[];
splitPagadorOptions: SelectOption[];
defaultPagadorId: string | null;
contaOptions: SelectOption[];
cartaoOptions: SelectOption[];
categoriaOptions: SelectOption[];
pagadorFilterOptions: FilterOption[];
categoriaFilterOptions: FilterOption[];
contaCartaoFilterOptions: ContaCartaoFilterOption[];
};
export const getSingleParam = (
params: ResolvedSearchParams,
key: string
): string | null => {
const value = params?.[key];
if (!value) {
return null;
}
return Array.isArray(value) ? value[0] ?? null : value;
};
export const extractLancamentoSearchFilters = (
params: ResolvedSearchParams
): LancamentoSearchFilters => ({
transactionFilter: getSingleParam(params, "transacao"),
conditionFilter: getSingleParam(params, "condicao"),
paymentFilter: getSingleParam(params, "pagamento"),
pagadorFilter: getSingleParam(params, "pagador"),
categoriaFilter: getSingleParam(params, "categoria"),
contaCartaoFilter: getSingleParam(params, "contaCartao"),
searchFilter: getSingleParam(params, "q"),
});
const normalizeLabel = (value: string | null | undefined) =>
value?.trim().length ? value.trim() : "Sem descrição";
const slugify = (value: string) => {
const base = value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base || "item";
};
const createSlugGenerator = () => {
const seen = new Map<string, number>();
return (label: string) => {
const base = slugify(label);
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
if (count === 0) {
return base;
}
return `${base}-${count + 1}`;
};
};
export const toOption = (
value: string,
label: string | null | undefined,
role?: string | null,
group?: string | null,
slug?: string | null,
avatarUrl?: string | null,
logo?: string | null,
icon?: string | null
): SelectOption => ({
value,
label: normalizeLabel(label),
role: role ?? null,
group: group ?? null,
slug: slug ?? null,
avatarUrl: avatarUrl ?? null,
logo: logo ?? null,
icon: icon ?? null,
});
export const fetchLancamentoFilterSources = async (userId: string) => {
const [pagadorRows, contaRows, cartaoRows, categoriaRows] = await Promise.all(
[
db.query.pagadores.findMany({
where: eq(pagadores.userId, userId),
}),
db.query.contas.findMany({
where: (contas, { eq, and }) =>
and(eq(contas.userId, userId), eq(contas.status, "Ativa")),
}),
db.query.cartoes.findMany({
where: (cartoes, { eq, and }) =>
and(eq(cartoes.userId, userId), eq(cartoes.status, "Ativo")),
}),
db.query.categorias.findMany({
where: eq(categorias.userId, userId),
}),
]
);
return { pagadorRows, contaRows, cartaoRows, categoriaRows };
};
export const buildSluggedFilters = ({
pagadorRows,
categoriaRows,
contaRows,
cartaoRows,
}: {
pagadorRows: PagadorRow[];
categoriaRows: CategoriaRow[];
contaRows: ContaRow[];
cartaoRows: CartaoRow[];
}): SluggedFilters => {
const pagadorSlugger = createSlugGenerator();
const categoriaSlugger = createSlugGenerator();
const contaCartaoSlugger = createSlugGenerator();
const pagadorFiltersRaw = pagadorRows.map((pagador) => {
const label = normalizeLabel(pagador.name);
return {
id: pagador.id,
label,
slug: pagadorSlugger(label),
role: pagador.role ?? null,
avatarUrl: pagador.avatarUrl ?? null,
};
});
const categoriaFiltersRaw = categoriaRows.map((categoria) => {
const label = normalizeLabel(categoria.name);
return {
id: categoria.id,
label,
slug: categoriaSlugger(label),
type: categoria.type ?? null,
icon: categoria.icon ?? null,
};
});
const contaFiltersRaw = contaRows.map((conta) => {
const label = normalizeLabel(conta.name);
return {
id: conta.id,
label,
slug: contaCartaoSlugger(label),
kind: "conta" as const,
logo: conta.logo ?? null,
};
});
const cartaoFiltersRaw = cartaoRows.map((cartao) => {
const label = normalizeLabel(cartao.name);
return {
id: cartao.id,
label,
slug: contaCartaoSlugger(label),
kind: "cartao" as const,
logo: cartao.logo ?? null,
};
});
return {
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
};
};
export const buildSlugMaps = ({
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
}: SluggedFilters): SlugMaps => ({
pagador: new Map(pagadorFiltersRaw.map(({ slug, id }) => [slug, id])),
categoria: new Map(categoriaFiltersRaw.map(({ slug, id }) => [slug, id])),
conta: new Map(contaFiltersRaw.map(({ slug, id }) => [slug, id])),
cartao: new Map(cartaoFiltersRaw.map(({ slug, id }) => [slug, id])),
});
const isValidTransaction = (
value: string | null
): value is (typeof LANCAMENTO_TRANSACTION_TYPES)[number] =>
!!value &&
(LANCAMENTO_TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
const isValidCondition = (
value: string | null
): value is (typeof LANCAMENTO_CONDITIONS)[number] =>
!!value && (LANCAMENTO_CONDITIONS as readonly string[]).includes(value ?? "");
const isValidPaymentMethod = (
value: string | null
): value is (typeof LANCAMENTO_PAYMENT_METHODS)[number] =>
!!value &&
(LANCAMENTO_PAYMENT_METHODS as readonly string[]).includes(value ?? "");
const buildSearchPattern = (value: string | null) =>
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
export const buildLancamentoWhere = ({
userId,
period,
filters,
slugMaps,
cardId,
accountId,
pagadorId,
}: {
userId: string;
period: string;
filters: LancamentoSearchFilters;
slugMaps: SlugMaps;
cardId?: string;
accountId?: string;
pagadorId?: string;
}): SQL[] => {
const where: SQL[] = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
];
if (pagadorId) {
where.push(eq(lancamentos.pagadorId, pagadorId));
}
if (cardId) {
where.push(eq(lancamentos.cartaoId, cardId));
}
if (accountId) {
where.push(eq(lancamentos.contaId, accountId));
}
if (isValidTransaction(filters.transactionFilter)) {
where.push(eq(lancamentos.transactionType, filters.transactionFilter));
}
if (isValidCondition(filters.conditionFilter)) {
where.push(eq(lancamentos.condition, filters.conditionFilter));
}
if (isValidPaymentMethod(filters.paymentFilter)) {
where.push(eq(lancamentos.paymentMethod, filters.paymentFilter));
}
if (!pagadorId && filters.pagadorFilter) {
const id = slugMaps.pagador.get(filters.pagadorFilter);
if (id) {
where.push(eq(lancamentos.pagadorId, id));
}
}
if (filters.categoriaFilter) {
const id = slugMaps.categoria.get(filters.categoriaFilter);
if (id) {
where.push(eq(lancamentos.categoriaId, id));
}
}
if (filters.contaCartaoFilter) {
const contaId = slugMaps.conta.get(filters.contaCartaoFilter);
const relatedCartaoId = contaId
? null
: slugMaps.cartao.get(filters.contaCartaoFilter);
if (contaId) {
where.push(eq(lancamentos.contaId, contaId));
}
if (!contaId && relatedCartaoId) {
where.push(eq(lancamentos.cartaoId, relatedCartaoId));
}
}
const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) {
where.push(
or(
ilike(lancamentos.name, searchPattern),
ilike(lancamentos.note, searchPattern)
)
);
}
return where;
};
type LancamentoRowWithRelations = typeof lancamentos.$inferSelect & {
pagador?: PagadorRow | null;
conta?: ContaRow | null;
cartao?: CartaoRow | null;
categoria?: CategoriaRow | null;
};
export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
rows.map((item) => ({
id: item.id,
name: item.name,
purchaseDate: item.purchaseDate?.toISOString() ?? new Date().toISOString(),
period: item.period ?? "",
transactionType: item.transactionType,
amount: Number(item.amount ?? 0),
condition: item.condition,
paymentMethod: item.paymentMethod,
pagadorId: item.pagadorId ?? null,
pagadorName: item.pagador?.name ?? null,
pagadorAvatar: item.pagador?.avatarUrl ?? null,
pagadorRole: item.pagador?.role ?? null,
contaId: item.contaId ?? null,
contaName: item.conta?.name ?? null,
contaLogo: item.conta?.logo ?? null,
cartaoId: item.cartaoId ?? null,
cartaoName: item.cartao?.name ?? null,
cartaoLogo: item.cartao?.logo ?? null,
categoriaId: item.categoriaId ?? null,
categoriaName: item.categoria?.name ?? null,
categoriaType: item.categoria?.type ?? null,
installmentCount: item.installmentCount ?? null,
recurrenceCount: item.recurrenceCount ?? null,
currentInstallment: item.currentInstallment ?? null,
dueDate: item.dueDate ? item.dueDate.toISOString().slice(0, 10) : null,
boletoPaymentDate: item.boletoPaymentDate
? item.boletoPaymentDate.toISOString().slice(0, 10)
: null,
note: item.note ?? null,
isSettled: item.isSettled ?? null,
isDivided: item.isDivided ?? false,
isAnticipated: item.isAnticipated ?? false,
anticipationId: item.anticipationId ?? null,
seriesId: item.seriesId ?? null,
readonly:
Boolean(item.note?.startsWith(ACCOUNT_AUTO_INVOICE_NOTE_PREFIX)) ||
item.categoria?.name === "Saldo inicial" ||
item.categoria?.name === "Pagamentos",
}));
const sortByLabel = <T extends { label: string }>(items: T[]) =>
items.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" })
);
export const buildOptionSets = ({
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
pagadorRows,
limitCartaoId,
limitContaId,
}: SluggedFilters & {
pagadorRows: PagadorRow[];
limitCartaoId?: string;
limitContaId?: string;
}): LancamentoOptionSets => {
const pagadorOptions = sortByLabel(
pagadorFiltersRaw.map(({ id, label, role, slug, avatarUrl }) =>
toOption(id, label, role, undefined, slug, avatarUrl)
)
);
const pagadorFilterOptions = sortByLabel(
pagadorFiltersRaw.map(({ slug, label, avatarUrl }) => ({
slug,
label,
avatarUrl,
}))
);
const defaultPagadorId =
pagadorRows.find((pagador) => pagador.role === PAGADOR_ROLE_ADMIN)?.id ??
null;
const splitPagadorOptions = pagadorOptions.filter(
(option) => option.role === PAGADOR_ROLE_TERCEIRO
);
const contaOptionsSource = limitContaId
? contaFiltersRaw.filter((conta) => conta.id === limitContaId)
: contaFiltersRaw;
const contaOptions = sortByLabel(
contaOptionsSource.map(({ id, label, slug, logo }) =>
toOption(id, label, undefined, undefined, slug, undefined, logo)
)
);
const cartaoOptionsSource = limitCartaoId
? cartaoFiltersRaw.filter((cartao) => cartao.id === limitCartaoId)
: cartaoFiltersRaw;
const cartaoOptions = sortByLabel(
cartaoOptionsSource.map(({ id, label, slug, logo }) =>
toOption(id, label, undefined, undefined, slug, undefined, logo)
)
);
const categoriaOptions = sortByLabel(
categoriaFiltersRaw.map(({ id, label, type, slug, icon }) =>
toOption(id, label, undefined, type, slug, undefined, undefined, icon)
)
);
const categoriaFilterOptions = sortByLabel(
categoriaFiltersRaw.map(({ slug, label, icon }) => ({ slug, label, icon }))
);
const contaCartaoFilterOptions = sortByLabel(
[...contaFiltersRaw, ...cartaoFiltersRaw]
.filter(
(option) =>
(limitCartaoId && option.kind === "cartao"
? option.id === limitCartaoId
: true) &&
(limitContaId && option.kind === "conta"
? option.id === limitContaId
: true)
)
.map(({ slug, label, kind, logo }) => ({ slug, label, kind, logo }))
);
return {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
};
};

40
lib/logo/index.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Logo utilities
*
* Consolidated from:
* - /lib/logo.ts (utility functions)
*/
/**
* Normalizes logo path to get just the filename
* @param logo - Logo path or URL
* @returns Filename only
*/
export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? "";
/**
* Derives a display name from a logo filename
* @param logo - Logo path or filename
* @returns Formatted display name
* @example
* deriveNameFromLogo("my-company-logo.png") // "My Company Logo"
*/
export const deriveNameFromLogo = (logo?: string | null) => {
if (!logo) {
return "";
}
const fileName = normalizeLogo(logo);
if (!fileName) {
return "";
}
const withoutExtension = fileName.replace(/\.[^/.]+$/, "");
return withoutExtension
.split(/[-_.\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
};

30
lib/logo/options.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Logo options loader
*
* Consolidated from:
* - /lib/logo-options.ts (async logo loading)
*/
import { readdir } from "node:fs/promises";
import path from "node:path";
const LOGOS_DIRECTORY = path.join(process.cwd(), "public", "logos");
const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
/**
* Loads available logo files from the public/logos directory
* @returns Array of logo filenames sorted alphabetically
*/
export async function loadLogoOptions() {
try {
const files = await readdir(LOGOS_DIRECTORY, { withFileTypes: true });
return files
.filter((file) => file.isFile())
.map((file) => file.name)
.filter((file) => LOGO_EXTENSIONS.has(path.extname(file).toLowerCase()))
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
} catch {
return [];
}
}

114
lib/pagadores/access.ts Normal file
View File

@@ -0,0 +1,114 @@
import {
pagadores,
pagadorShares,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
};
export async function fetchPagadoresWithAccess(
userId: string
): Promise<PagadorWithAccess[]> {
const [owned, shared] = await Promise.all([
db.query.pagadores.findMany({
where: eq(pagadores.userId, userId),
}),
db
.select({
shareId: pagadorShares.id,
pagador: pagadores,
ownerName: usersTable.name,
ownerEmail: usersTable.email,
})
.from(pagadorShares)
.innerJoin(
pagadores,
eq(pagadorShares.pagadorId, pagadores.id)
)
.leftJoin(
usersTable,
eq(pagadores.userId, usersTable.id)
)
.where(eq(pagadorShares.sharedWithUserId, userId)),
]);
const ownedMapped: PagadorWithAccess[] = owned.map((item) => ({
...item,
canEdit: true,
sharedByName: null,
sharedByEmail: null,
shareId: null,
}));
const sharedMapped: PagadorWithAccess[] = shared.map((item) => ({
...item.pagador,
shareCode: null,
canEdit: false,
sharedByName: item.ownerName ?? null,
sharedByEmail: item.ownerEmail ?? null,
shareId: item.shareId,
}));
return [...ownedMapped, ...sharedMapped];
}
export async function getPagadorAccess(
userId: string,
pagadorId: string
) {
const pagador = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId)),
});
if (!pagador) {
return null;
}
if (pagador.userId === userId) {
return {
pagador,
canEdit: true,
share: null as typeof pagadorShares.$inferSelect | null,
};
}
const share = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId)
),
});
if (!share) {
return null;
}
return { pagador, canEdit: false, share };
}
export async function userCanEditPagador(
userId: string,
pagadorId: string
) {
const pagadorRow = await db.query.pagadores.findFirst({
columns: { id: true },
where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)),
});
return Boolean(pagadorRow);
}
export async function userHasPagadorAccess(
userId: string,
pagadorId: string
) {
const access = await getPagadorAccess(userId, pagadorId);
return Boolean(access);
}

View File

@@ -0,0 +1,13 @@
/**
* Pagador constants
*
* Extracted from /lib/pagadores.ts
*/
export const PAGADOR_STATUS_OPTIONS = ["Ativo", "Inativo"] as const;
export type PagadorStatus = (typeof PAGADOR_STATUS_OPTIONS)[number];
export const PAGADOR_ROLE_ADMIN = "admin";
export const PAGADOR_ROLE_TERCEIRO = "terceiro";
export const DEFAULT_PAGADOR_AVATAR = "avatar_010.svg";

60
lib/pagadores/defaults.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Pagador defaults - User seeding logic
*
* Moved from /lib/pagador-defaults.ts to /lib/pagadores/defaults.ts
*/
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
} from "./constants";
import { normalizeNameFromEmail } from "./utils";
import { eq } from "drizzle-orm";
const DEFAULT_STATUS = PAGADOR_STATUS_OPTIONS[0];
interface SeedUserLike {
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
const userId = user.id;
if (!userId) {
return;
}
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
if (hasAnyPagador) {
return;
}
const name =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Pagador principal";
// Usa a imagem do Google se disponível, senão usa o avatar padrão
const avatarUrl = user.image ?? DEFAULT_PAGADOR_AVATAR;
await db.insert(pagadores).values({
name,
email: user.email ?? null,
status: DEFAULT_STATUS,
role: PAGADOR_ROLE_ADMIN,
avatarUrl,
note: null,
isAutoSend: false,
userId,
});
}

325
lib/pagadores/details.ts Normal file
View File

@@ -0,0 +1,325 @@
import { cartoes, lancamentos } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import {
and,
eq,
gte,
ilike,
isNull,
lte,
not,
or,
sum,
} from "drizzle-orm";
import { sql } from "drizzle-orm";
const RECEITA = "Receita";
const DESPESA = "Despesa";
const PAYMENT_METHOD_CARD = "Cartão de crédito";
const PAYMENT_METHOD_BOLETO = "Boleto";
export type PagadorMonthlyBreakdown = {
totalExpenses: number;
totalIncomes: number;
paymentSplits: Record<"card" | "boleto" | "instant", number>;
};
export type PagadorHistoryPoint = {
period: string;
label: string;
receitas: number;
despesas: number;
};
export type PagadorCardUsageItem = {
id: string;
name: string;
logo: string | null;
amount: number;
};
export type PagadorBoletoStats = {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
};
const toNumber = (value: string | number | bigint | null) => {
if (typeof value === "number") {
return value;
}
if (typeof value === "bigint") {
return Number(value);
}
if (!value) {
return 0;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? 0 : parsed;
};
const formatPeriod = (year: number, month: number) =>
`${year}-${String(month).padStart(2, "0")}`;
const normalizePeriod = (period: string) => {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month)) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
};
const buildPeriodWindow = (period: string, months: number) => {
const { year, month } = normalizePeriod(period);
const items: string[] = [];
let currentYear = year;
let currentMonth = month;
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
return items;
};
const formatPeriodLabel = (period: string) => {
try {
const { year, month } = normalizePeriod(period);
const formatter = new Intl.DateTimeFormat("pt-BR", {
month: "short",
});
const date = new Date(year, month - 1, 1);
const rawLabel = formatter.format(date).replace(".", "");
const label =
rawLabel.length > 0
? rawLabel.charAt(0).toUpperCase().concat(rawLabel.slice(1))
: rawLabel;
const suffix = String(year).slice(-2);
return `${label}/${suffix}`;
} catch {
return period;
}
};
const excludeAutoInvoiceEntries = () =>
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`))
);
type BaseFilters = {
userId: string;
pagadorId: string;
period: string;
};
export async function fetchPagadorMonthlyBreakdown({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorMonthlyBreakdown> {
const rows = await db
.select({
paymentMethod: lancamentos.paymentMethod,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.paymentMethod, lancamentos.transactionType);
const paymentSplits: PagadorMonthlyBreakdown["paymentSplits"] = {
card: 0,
boleto: 0,
instant: 0,
};
let totalExpenses = 0;
let totalIncomes = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
totalExpenses += total;
if (row.paymentMethod === PAYMENT_METHOD_CARD) {
paymentSplits.card += total;
} else if (row.paymentMethod === PAYMENT_METHOD_BOLETO) {
paymentSplits.boleto += total;
} else {
paymentSplits.instant += total;
}
} else if (row.transactionType === RECEITA) {
totalIncomes += total;
}
}
return {
totalExpenses,
totalIncomes,
paymentSplits,
};
}
export async function fetchPagadorHistory({
userId,
pagadorId,
period,
months = 6,
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
const window = buildPeriodWindow(period, months);
const start = window[0];
const end = window[window.length - 1];
const rows = await db
.select({
period: lancamentos.period,
transactionType: lancamentos.transactionType,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
gte(lancamentos.period, start),
lte(lancamentos.period, end),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.period, lancamentos.transactionType);
const totalsByPeriod = new Map<
string,
{ receitas: number; despesas: number }
>();
for (const key of window) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
for (const row of rows) {
const key = row.period ?? undefined;
if (!key || !totalsByPeriod.has(key)) continue;
const bucket = totalsByPeriod.get(key);
if (!bucket) continue;
const total = Math.abs(toNumber(row.totalAmount));
if (row.transactionType === DESPESA) {
bucket.despesas += total;
} else if (row.transactionType === RECEITA) {
bucket.receitas += total;
}
}
return window.map((key) => ({
period: key,
label: formatPeriodLabel(key),
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
}));
}
export async function fetchPagadorCardUsage({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorCardUsageItem[]> {
const rows = await db
.select({
cartaoId: lancamentos.cartaoId,
cardName: cartoes.name,
cardLogo: cartoes.logo,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_CARD),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
return rows
.filter((row) => Boolean(row.cartaoId))
.map((row) => {
if (!row.cartaoId) {
throw new Error("cartaoId should not be null after filter");
}
return {
id: row.cartaoId,
name: row.cardName ?? "Cartão",
logo: row.cardLogo ?? null,
amount: Math.abs(toNumber(row.totalAmount)),
};
})
.sort((a, b) => b.amount - a.amount);
}
export async function fetchPagadorBoletoStats({
userId,
pagadorId,
period,
}: BaseFilters): Promise<PagadorBoletoStats> {
const rows = await db
.select({
isSettled: lancamentos.isSettled,
totalAmount: sum(lancamentos.amount).as("total"),
totalCount: sql<number>`count(${lancamentos.id})`.as("count"),
})
.from(lancamentos)
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.pagadorId, pagadorId),
eq(lancamentos.period, period),
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
excludeAutoInvoiceEntries()
)
)
.groupBy(lancamentos.isSettled);
let paidAmount = 0;
let pendingAmount = 0;
let paidCount = 0;
let pendingCount = 0;
for (const row of rows) {
const total = Math.abs(toNumber(row.totalAmount));
const count = toNumber(row.totalCount);
if (row.isSettled) {
paidAmount += total;
paidCount += count;
} else {
pendingAmount += total;
pendingCount += count;
}
}
return {
totalAmount: paidAmount + pendingAmount,
paidAmount,
pendingAmount,
paidCount,
pendingCount,
};
}

View File

@@ -0,0 +1,237 @@
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { inArray } from "drizzle-orm";
import { Resend } from "resend";
type ActionType = "created" | "deleted";
export type NotificationEntry = {
pagadorId: string;
name: string | null;
amount: number;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | null;
period: string | null;
note: string | null;
};
export type PagadorNotificationRequest = {
userLabel: string;
action: ActionType;
entriesByPagador: Map<string, NotificationEntry[]>;
};
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
const formatDate = (value: Date | null) => {
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const buildHtmlBody = ({
userLabel,
action,
entries,
}: {
userLabel: string;
action: ActionType;
entries: NotificationEntry[];
}) => {
const actionLabel =
action === "created" ? "Novo lançamento registrado" : "Lançamento removido";
const actionDescription =
action === "created"
? "Um novo lançamento foi registrado em seu nome."
: "Um lançamento anteriormente registrado em seu nome foi removido.";
const rows = entries
.map((entry) => {
const label =
entry.transactionType === "Despesa"
? formatCurrency(Math.abs(entry.amount)) + " (Despesa)"
: formatCurrency(Math.abs(entry.amount)) + " (Receita)";
return `<tr>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${formatDate(
entry.purchaseDate
)}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.name ?? "Sem descrição"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.paymentMethod ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.condition ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
</tr>`;
})
.join("");
return `
<div style="font-family:'Inter',Arial,sans-serif;color:#0f172a;line-height:1.5;">
<h2 style="margin:0 0 8px 0;font-size:20px;">${actionLabel}</h2>
<p style="margin:0 0 16px 0;color:#475569;">${actionDescription}</p>
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Data</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Descrição</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Pagamento</th>
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Condição</th>
<th style="text-align:right;padding:8px;border-bottom:1px solid #e2e8f0;">Valor</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
<p style="margin:0;font-size:12px;color:#94a3b8;">
Enviado automaticamente por ${userLabel} via OpenSheets.
</p>
</div>
`;
};
export async function sendPagadorAutoEmails({
userLabel,
action,
entriesByPagador,
}: PagadorNotificationRequest) {
"use server";
if (entriesByPagador.size === 0) {
return;
}
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "OpenSheets <onboarding@resend.dev>";
if (!resendApiKey) {
console.warn(
"RESEND_API_KEY não configurada. Envio automático de lançamentos ignorado."
);
return;
}
const pagadorIds = Array.from(entriesByPagador.keys());
if (pagadorIds.length === 0) {
return;
}
const pagadorRows = await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
if (pagadorRows.length === 0) {
return;
}
const resend = new Resend(resendApiKey);
const subjectPrefix =
action === "created" ? "Novo lançamento" : "Lançamento removido";
const results = await Promise.allSettled(
pagadorRows.map(async (pagador) => {
if (!pagador.email || !pagador.isAutoSend) {
return;
}
const entries = entriesByPagador.get(pagador.id);
if (!entries || entries.length === 0) {
return;
}
const html = buildHtmlBody({
userLabel,
action,
entries,
});
await resend.emails.send({
from: resendFrom,
to: pagador.email,
subject: `${subjectPrefix}${pagador.name}`,
html,
});
})
);
// Log any failed email sends
results.forEach((result, index) => {
if (result.status === "rejected") {
const pagador = pagadorRows[index];
console.error(
`Failed to send email notification to ${pagador?.name} (${pagador?.email}):`,
result.reason
);
}
});
}
export type RawNotificationRecord = {
pagadorId: string | null;
name: string | null;
amount: string | number | null;
transactionType: string | null;
paymentMethod: string | null;
condition: string | null;
purchaseDate: Date | string | null;
period: string | null;
note: string | null;
};
export const buildEntriesByPagador = (
records: RawNotificationRecord[]
): Map<string, NotificationEntry[]> => {
const map = new Map<string, NotificationEntry[]>();
records.forEach((record) => {
if (!record.pagadorId) {
return;
}
const amount =
typeof record.amount === "number"
? record.amount
: Number(record.amount ?? 0);
const purchaseDate =
record.purchaseDate instanceof Date
? record.purchaseDate
: record.purchaseDate
? new Date(record.purchaseDate)
: null;
const entry: NotificationEntry = {
pagadorId: record.pagadorId,
name: record.name ?? null,
amount,
transactionType: record.transactionType ?? null,
paymentMethod: record.paymentMethod ?? null,
condition: record.condition ?? null,
purchaseDate,
period: record.period ?? null,
note: record.note ?? null,
};
const list = map.get(record.pagadorId) ?? [];
list.push(entry);
map.set(record.pagadorId, list);
});
return map;
};

65
lib/pagadores/utils.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Pagador utility functions
*
* Extracted from /lib/pagadores.ts
*/
import { DEFAULT_PAGADOR_AVATAR } from "./constants";
/**
* Normaliza o caminho do avatar extraindo apenas o nome do arquivo.
* Remove qualquer caminho anterior e retorna null se não houver avatar.
*/
export const normalizeAvatarPath = (
avatar: string | null | undefined
): string | null => {
if (!avatar) return null;
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
};
/**
* Retorna o caminho completo para o avatar, com fallback para o avatar padrão.
* Se o avatar for uma URL completa (http/https/data), retorna diretamente.
*/
export const getAvatarSrc = (avatar: string | null | undefined): string => {
if (!avatar) {
return `/avatares/${DEFAULT_PAGADOR_AVATAR}`;
}
// Se for uma URL completa (Google, etc), retorna diretamente
if (
avatar.startsWith("http://") ||
avatar.startsWith("https://") ||
avatar.startsWith("data:")
) {
return avatar;
}
// Se for um caminho local, normaliza e adiciona o prefixo
const normalized = normalizeAvatarPath(avatar);
return `/avatares/${normalized ?? DEFAULT_PAGADOR_AVATAR}`;
};
/**
* Normaliza nome a partir de email
*/
export const normalizeNameFromEmail = (
email: string | null | undefined
): string => {
if (!email) {
return "Novo pagador";
}
const [local] = email.split("@");
if (!local) {
return "Novo pagador";
}
return local
.split(".")
.map((segment) =>
segment.length > 0
? segment[0]?.toUpperCase().concat(segment.slice(1))
: segment
)
.join(" ");
};

134
lib/schemas/common.ts Normal file
View File

@@ -0,0 +1,134 @@
import { z } from "zod";
/**
* Common Zod schemas for reuse across the application
*/
/**
* UUID schema with custom error message
*/
export const uuidSchema = (entityName: string = "ID") =>
z.string({ message: `${entityName} inválido.` }).uuid(`${entityName} inválido.`);
/**
* Decimal string schema - parses string with comma/period to number
*/
export const decimalSchema = z
.string()
.trim()
.transform((value) => value.replace(/\s/g, "").replace(",", "."))
.refine(
(value) => !Number.isNaN(Number.parseFloat(value)),
"Informe um valor numérico válido."
)
.transform((value) => Number.parseFloat(value));
/**
* Optional/nullable decimal string schema
*/
export const optionalDecimalSchema = z
.string()
.trim()
.optional()
.transform((value) =>
value && value.length > 0 ? value.replace(",", ".") : null
)
.refine(
(value) => value === null || !Number.isNaN(Number.parseFloat(value)),
"Informe um valor numérico válido."
)
.transform((value) => (value === null ? null : Number.parseFloat(value)));
/**
* Day of month schema (1-31)
*/
export const dayOfMonthSchema = z
.string({ message: "Informe o dia." })
.trim()
.min(1, "Informe o dia.")
.refine((value) => {
const parsed = Number.parseInt(value, 10);
return !Number.isNaN(parsed) && parsed >= 1 && parsed <= 31;
}, "Informe um dia entre 1 e 31.");
/**
* Period schema (YYYY-MM format)
*/
export const periodSchema = z
.string({ message: "Informe o período." })
.trim()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Período inválido.");
/**
* Optional period schema
*/
export const optionalPeriodSchema = z
.string()
.trim()
.regex(/^(\d{4})-(\d{2})$/, {
message: "Selecione um período válido.",
})
.optional();
/**
* Date string schema
*/
export const dateStringSchema = z
.string({ message: "Informe a data." })
.trim()
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
message: "Data inválida.",
});
/**
* Optional date string schema
*/
export const optionalDateStringSchema = z
.string()
.trim()
.refine((value) => !value || !Number.isNaN(new Date(value).getTime()), {
message: "Informe uma data válida.",
})
.optional();
/**
* Note/observation schema (max 500 chars, trimmed, nullable)
*/
export const noteSchema = z
.string()
.trim()
.max(500, "A anotação deve ter no máximo 500 caracteres.")
.optional()
.transform((value) => (value && value.length > 0 ? value : null));
/**
* Optional string that becomes null if empty
*/
export const optionalStringToNull = z
.string()
.trim()
.optional()
.transform((value) => (value && value.length > 0 ? value : null));
/**
* Required non-empty string schema
*/
export const requiredStringSchema = (fieldName: string) =>
z
.string({ message: `Informe ${fieldName}.` })
.trim()
.min(1, `Informe ${fieldName}.`);
/**
* Amount schema with minimum value validation
*/
export const amountSchema = z.coerce
.number({ message: "Informe o valor." })
.min(0, "Informe um valor maior ou igual a zero.");
/**
* Positive amount schema
*/
export const positiveAmountSchema = z.coerce
.number({ message: "Informe o valor." })
.positive("Informe um valor maior que zero.");

69
lib/schemas/insights.ts Normal file
View File

@@ -0,0 +1,69 @@
import { z } from "zod";
/**
* Categorias de insights
*/
export const INSIGHT_CATEGORIES = {
behaviors: {
id: "behaviors",
title: "Comportamentos Observados",
icon: "RiEyeLine",
color: "blue",
},
triggers: {
id: "triggers",
title: "Gatilhos de Consumo",
icon: "RiFlashlightLine",
color: "amber",
},
recommendations: {
id: "recommendations",
title: "Recomendações Práticas",
icon: "RiLightbulbLine",
color: "green",
},
improvements: {
id: "improvements",
title: "Melhorias Sugeridas",
icon: "RiRocketLine",
color: "purple",
},
} as const;
export type InsightCategoryId = keyof typeof INSIGHT_CATEGORIES;
/**
* Schema para item individual de insight
*/
export const InsightItemSchema = z.object({
text: z.string().min(1),
});
/**
* Schema para categoria de insights
*/
export const InsightCategorySchema = z.object({
category: z.enum([
"behaviors",
"triggers",
"recommendations",
"improvements",
]),
items: z.array(InsightItemSchema).min(1).max(6),
});
/**
* Schema for complete insights response from AI
*/
export const InsightsResponseSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/), // YYYY-MM
generatedAt: z.string(), // ISO datetime
categories: z.array(InsightCategorySchema).length(4),
});
/**
* TypeScript types derived from schemas
*/
export type InsightItem = z.infer<typeof InsightItemSchema>;
export type InsightCategory = z.infer<typeof InsightCategorySchema>;
export type InsightsResponse = z.infer<typeof InsightsResponseSchema>;

View File

@@ -0,0 +1,5 @@
export const TRANSFER_CATEGORY_NAME = "Transferência interna";
export const TRANSFER_ESTABLISHMENT = "Transf. entre contas";
export const TRANSFER_PAGADOR = "Admin";
export const TRANSFER_PAYMENT_METHOD = "Pix";
export const TRANSFER_CONDITION = "À vista";

168
lib/utils/calculator.ts Normal file
View File

@@ -0,0 +1,168 @@
export type Operator = "add" | "subtract" | "multiply" | "divide";
export const OPERATOR_SYMBOLS: Record<Operator, string> = {
add: "+",
subtract: "-",
multiply: "×",
divide: "÷",
};
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return "Erro";
}
const rounded = Number(Math.round(value * 1e10) / 1e10);
return rounded.toString();
}
export function formatLocaleValue(rawValue: string): string {
if (rawValue === "Erro") {
return rawValue;
}
const isNegative = rawValue.startsWith("-");
const unsignedValue = isNegative ? rawValue.slice(1) : rawValue;
if (unsignedValue === "") {
return isNegative ? "-0" : "0";
}
const hasDecimalSeparator = unsignedValue.includes(".");
const [integerPartRaw, decimalPartRaw] = unsignedValue.split(".");
const integerPart = integerPartRaw || "0";
const decimalPart = hasDecimalSeparator ? (decimalPartRaw ?? "") : undefined;
const numericInteger = Number(integerPart);
const formattedInteger = Number.isFinite(numericInteger)
? numericInteger.toLocaleString("pt-BR")
: integerPart;
if (decimalPart === undefined) {
return `${isNegative ? "-" : ""}${formattedInteger}`;
}
return `${isNegative ? "-" : ""}${formattedInteger},${decimalPart}`;
}
export function performOperation(
a: number,
b: number,
operator: Operator,
): number {
switch (operator) {
case "add":
return a + b;
case "subtract":
return a - b;
case "multiply":
return a * b;
case "divide":
return b === 0 ? Infinity : a / b;
default:
return b;
}
}
// Trata colagem de valores com formatação brasileira (ponto para milhar, vírgula para decimal)
// e variações simples em formato internacional.
export function normalizeClipboardNumber(rawValue: string): string | null {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/-?[\d.,\s]+/);
if (!match) {
return null;
}
let extracted = match[0].replace(/\s+/g, "");
if (!extracted) {
return null;
}
const isNegative = extracted.startsWith("-");
if (isNegative) {
extracted = extracted.slice(1);
}
extracted = extracted.replace(/[^\d.,]/g, "");
if (!extracted) {
return null;
}
const countOccurrences = (char: string) =>
(extracted.match(new RegExp(`\\${char}`, "g")) ?? []).length;
const hasComma = extracted.includes(",");
const hasDot = extracted.includes(".");
let decimalSeparator: "," | "." | null = null;
if (hasComma && hasDot) {
decimalSeparator =
extracted.lastIndexOf(",") > extracted.lastIndexOf(".") ? "," : ".";
} else if (hasComma) {
const commaCount = countOccurrences(",");
if (commaCount > 1) {
decimalSeparator = null;
} else {
const digitsAfterComma =
extracted.length - extracted.lastIndexOf(",") - 1;
decimalSeparator =
digitsAfterComma > 0 && digitsAfterComma <= 2 ? "," : null;
}
} else if (hasDot) {
const dotCount = countOccurrences(".");
if (dotCount > 1) {
decimalSeparator = null;
} else {
const digitsAfterDot = extracted.length - extracted.lastIndexOf(".") - 1;
const decimalCandidate = extracted.slice(extracted.lastIndexOf(".") + 1);
const allZeros = /^0+$/.test(decimalCandidate);
const shouldTreatAsDecimal =
digitsAfterDot > 0 &&
digitsAfterDot <= 3 &&
!(digitsAfterDot === 3 && allZeros);
decimalSeparator = shouldTreatAsDecimal ? "." : null;
}
}
let integerPart = extracted;
let decimalPart = "";
if (decimalSeparator) {
const decimalIndex = extracted.lastIndexOf(decimalSeparator);
integerPart = extracted.slice(0, decimalIndex);
decimalPart = extracted.slice(decimalIndex + 1);
}
integerPart = integerPart.replace(/[^\d]/g, "");
decimalPart = decimalPart.replace(/[^\d]/g, "");
if (!integerPart) {
integerPart = "0";
}
let normalized = integerPart;
if (decimalPart) {
normalized = `${integerPart}.${decimalPart}`;
}
if (isNegative && Number(normalized) !== 0) {
normalized = `-${normalized}`;
}
if (!/^(-?\d+(\.\d+)?)$/.test(normalized)) {
return null;
}
const numericValue = Number(normalized);
if (!Number.isFinite(numericValue)) {
return null;
}
return normalized;
}

60
lib/utils/currency.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Utility functions for currency/decimal formatting and parsing
*/
/**
* Formats a decimal number for database storage (2 decimal places)
* @param value - The number to format
* @returns Formatted string with 2 decimal places, or null if input is null
*/
export function formatDecimalForDb(value: number | null): string | null {
if (value === null) {
return null;
}
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Formats a decimal number for database storage (non-nullable version)
* @param value - The number to format
* @returns Formatted string with 2 decimal places
*/
export function formatDecimalForDbRequired(value: number): string {
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Normalizes decimal input by replacing comma with period
* @param value - Input string
* @returns Normalized string with period as decimal separator
*/
export function normalizeDecimalInput(value: string): string {
return value.replace(/\s/g, "").replace(",", ".");
}
/**
* Formats a limit/balance input for display
* @param value - The number to format
* @returns Formatted string or empty string
*/
export function formatLimitInput(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "";
}
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
* Formats an initial balance input for display (defaults to "0.00")
* @param value - The number to format
* @returns Formatted string with default "0.00"
*/
export function formatInitialBalanceInput(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "0.00";
}
return (Math.round(value * 100) / 100).toFixed(2);
}

235
lib/utils/date.ts Normal file
View File

@@ -0,0 +1,235 @@
/**
* Date utilities - Functions for date manipulation and formatting
*
* This module consolidates date-related utilities from:
* - /lib/utils/date.ts (basic date manipulation)
* - /lib/date/index.ts (formatting and display)
*
* Note: Period-related functions (YYYY-MM) are in /lib/utils/period
*/
// ============================================================================
// CONSTANTS
// ============================================================================
const WEEKDAY_NAMES = [
"Domingo",
"Segunda",
"Terça",
"Quarta",
"Quinta",
"Sexta",
"Sábado",
] as const;
const MONTH_NAMES = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
// ============================================================================
// DATE CREATION & MANIPULATION
// ============================================================================
/**
* Gets today's date in UTC
* @returns Date object set to today at midnight UTC
*/
export function getTodayUTC(): Date {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const day = now.getUTCDate();
return new Date(Date.UTC(year, month, day));
}
/**
* Gets today's date in local timezone
* @returns Date object set to today at midnight local time
*/
export function getTodayLocal(): Date {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
return new Date(year, month, day);
}
/**
* Gets today's period in YYYY-MM format (UTC)
* @returns Period string
*/
export function getTodayPeriodUTC(): string {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
return `${year}-${String(month + 1).padStart(2, "0")}`;
}
/**
* Formats date as YYYY-MM-DD string
* @param date - Date to format
* @returns Formatted date string
*/
export function formatDateForDb(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Gets today's date as YYYY-MM-DD string
* @returns Formatted date string
*/
export function getTodayDateString(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Gets today's date as Date object
* @returns Date object for today
*/
export function getTodayDate(): Date {
return new Date(getTodayDateString());
}
/**
* Gets today's info (date and period)
* @returns Object with date and period
*/
export function getTodayInfo(): { date: Date; period: string } {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const day = now.getUTCDate();
return {
date: new Date(Date.UTC(year, month, day)),
period: `${year}-${String(month + 1).padStart(2, "0")}`,
};
}
/**
* Adds months to a date
* @param value - Date to add months to
* @param offset - Number of months to add (can be negative)
* @returns New date with months added
*/
export function addMonthsToDate(value: Date, offset: number): Date {
const result = new Date(value);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + offset);
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0
).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
}
// ============================================================================
// DATE FORMATTING
// ============================================================================
/**
* Formats a date string (YYYY-MM-DD) to short display format
* @example
* formatDate("2024-11-14") // "qui 14 nov"
*/
export function formatDate(value: string): string {
const [year, month, day] = value.split("-");
const parsed = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
return new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
})
.format(parsed)
.replace(".", "")
.replace(" de", "");
}
/**
* Formats a date to friendly long format
* @example
* friendlyDate(new Date()) // "Segunda, 14 de novembro de 2025"
*/
export function friendlyDate(date: Date): string {
const weekday = WEEKDAY_NAMES[date.getDay()];
const day = date.getDate();
const month = MONTH_NAMES[date.getMonth()];
const year = date.getFullYear();
return `${weekday}, ${day} de ${month} de ${year}`;
}
// ============================================================================
// TIME-BASED UTILITIES
// ============================================================================
/**
* Gets appropriate greeting based on time of day
* @param date - Date to get greeting for (defaults to now)
* @returns "Bom dia", "Boa tarde", or "Boa noite"
*/
export function getGreeting(date: Date = new Date()): string {
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "Bom dia";
if (hour >= 12 && hour < 18) return "Boa tarde";
return "Boa noite";
}
// ============================================================================
// DATE INFORMATION
// ============================================================================
/**
* Gets information about a date
* @param date - Date to analyze (defaults to now)
* @returns Object with date information
*/
export function getDateInfo(date: Date = new Date()) {
return {
date,
year: date.getFullYear(),
month: date.getMonth() + 1,
monthName: MONTH_NAMES[date.getMonth()],
day: date.getDate(),
weekday: WEEKDAY_NAMES[date.getDay()],
friendlyDisplay: friendlyDate(date),
greeting: getGreeting(date),
};
}
// Re-export MONTH_NAMES for convenience
export { MONTH_NAMES };

61
lib/utils/icons.tsx Normal file
View File

@@ -0,0 +1,61 @@
import * as RemixIcons from "@remixicon/react";
import type { ComponentType, ReactNode } from "react";
const ICON_CLASS = "h-4 w-4";
const normalizeKey = (value: string) =>
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
export const getIconComponent = (
iconName: string
): ComponentType<{ className?: string }> | null => {
// Busca o ícone no objeto de ícones do Remix Icon
const icon = (RemixIcons as Record<string, unknown>)[iconName];
if (icon && typeof icon === "function") {
return icon as ComponentType<{ className?: string }>;
}
return null;
};
export const getConditionIcon = (condition: string): ReactNode => {
const key = normalizeKey(condition);
const registry: Record<string, ReactNode> = {
parcelado: <RemixIcons.RiLoader2Fill className={ICON_CLASS} aria-hidden />,
recorrente: <RemixIcons.RiRefreshLine className={ICON_CLASS} aria-hidden />,
avista: <RemixIcons.RiCheckLine className={ICON_CLASS} aria-hidden />,
vista: <RemixIcons.RiCheckLine className={ICON_CLASS} aria-hidden />,
};
return registry[key] ?? null;
};
export const getPaymentMethodIcon = (paymentMethod: string): ReactNode => {
const key = normalizeKey(paymentMethod);
const registry: Record<string, ReactNode> = {
dinheiro: (
<RemixIcons.RiMoneyDollarCircleLine className={ICON_CLASS} aria-hidden />
),
pix: <RemixIcons.RiPixLine className={ICON_CLASS} aria-hidden />,
boleto: <RemixIcons.RiBarcodeLine className={ICON_CLASS} aria-hidden />,
credito: (
<RemixIcons.RiMoneyDollarCircleLine className={ICON_CLASS} aria-hidden />
),
cartaodecredito: (
<RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />
),
cartaodedebito: (
<RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />
),
debito: <RemixIcons.RiBankCardLine className={ICON_CLASS} aria-hidden />,
};
return registry[key] ?? null;
};

51
lib/utils/math.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Utility functions for mathematical calculations
*/
/**
* Calculates percentage change between two values
* @param current - Current value
* @param previous - Previous value
* @returns Percentage change or null if previous is 0 and current is also 0
*/
export function 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;
}
/**
* Calculates percentage of part relative to total
* @param part - Part value
* @param total - Total value
* @returns Percentage (0-100)
*/
export function calculatePercentage(part: number, total: number): number {
if (total === 0) {
return 0;
}
return (part / total) * 100;
}
/**
* Rounds number to specified decimal places
* @param value - Value to round
* @param decimals - Number of decimal places (default 2)
* @returns Rounded number
*/
export function roundToDecimals(value: number, decimals: number = 2): number {
const multiplier = 10 ** decimals;
return Math.round(value * multiplier) / multiplier;
}

74
lib/utils/number.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Utility functions for safe number conversions
*/
/**
* Safely converts unknown value to number
* @param value - Value to convert
* @param defaultValue - Default value if conversion fails
* @returns Converted number or default value
*/
export function safeToNumber(
value: unknown,
defaultValue: number = 0
): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
if (value === null || value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
/**
* Safely parses integer from unknown value
* @param value - Value to parse
* @param defaultValue - Default value if parsing fails
* @returns Parsed integer or default value
*/
export function safeParseInt(
value: unknown,
defaultValue: number = 0
): number {
if (typeof value === "number") {
return Math.trunc(value);
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
}
/**
* Safely parses float from unknown value
* @param value - Value to parse
* @param defaultValue - Default value if parsing fails
* @returns Parsed float or default value
*/
export function safeParseFloat(
value: unknown,
defaultValue: number = 0
): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
}

381
lib/utils/period/index.ts Normal file
View File

@@ -0,0 +1,381 @@
/**
* Period utilities - Consolidated module for period (YYYY-MM) manipulation
*
* This module consolidates period-related functionality from:
* - /lib/month-period.ts (URL param handling)
* - /lib/period/index.ts (YYYY-MM operations)
* - /hooks/use-dates.ts (month navigation)
* - /lib/lancamentos/period-helpers.ts (formatting)
*
* Moved from /lib/period to /lib/utils/period for better organization
*/
// ============================================================================
// CONSTANTS
// ============================================================================
export const MONTH_NAMES = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
export type MonthName = (typeof MONTH_NAMES)[number];
// ============================================================================
// CORE PARSING & FORMATTING (YYYY-MM format)
// ============================================================================
/**
* Parses period string into year and month
* @param period - Period string in YYYY-MM format
* @returns Object with year and month numbers
* @throws Error if period format is invalid
*/
export function parsePeriod(period: string): { year: number; month: number } {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const month = Number.parseInt(monthStr ?? "", 10);
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
}
/**
* Formats year and month into period string
* @param year - Year number
* @param month - Month number (1-12)
* @returns Period string in YYYY-MM format
*/
export function formatPeriod(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}`;
}
/**
* Validates if period string is valid
* @param period - Period string to validate
* @returns True if valid, false otherwise
*/
export function isPeriodValid(period: string): boolean {
try {
parsePeriod(period);
return true;
} catch {
return false;
}
}
// ============================================================================
// PERIOD NAVIGATION
// ============================================================================
/**
* Returns the current period in YYYY-MM format
* @example
* getCurrentPeriod() // "2025-11"
*/
export function getCurrentPeriod(): string {
const now = new Date();
return formatPeriod(now.getFullYear(), now.getMonth() + 1);
}
/**
* Gets the previous period
* @param period - Current period in YYYY-MM format
* @returns Previous period string
*/
export function getPreviousPeriod(period: string): string {
const { year, month } = parsePeriod(period);
if (month === 1) {
return formatPeriod(year - 1, 12);
}
return formatPeriod(year, month - 1);
}
/**
* Gets the next period
* @param period - Current period in YYYY-MM format
* @returns Next period string
*/
export function getNextPeriod(period: string): string {
const { year, month } = parsePeriod(period);
if (month === 12) {
return formatPeriod(year + 1, 1);
}
return formatPeriod(year, month + 1);
}
/**
* Adds months to a period
* @param period - Period string in YYYY-MM format
* @param offset - Number of months to add (can be negative)
* @returns New period string
*/
export function addMonthsToPeriod(period: string, offset: number): string {
const { year: baseYear, month: baseMonth } = parsePeriod(period);
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = date.getMonth() + 1;
return formatPeriod(nextYear, nextMonth);
}
/**
* Gets the last N periods including the current one
* @param current - Current period in YYYY-MM format
* @param length - Number of periods to return
* @returns Array of period strings
*/
export function getLastPeriods(current: string, length: number): string[] {
const periods: string[] = [];
for (let offset = length - 1; offset >= 0; offset -= 1) {
periods.push(addMonthsToPeriod(current, -offset));
}
return periods;
}
// ============================================================================
// PERIOD COMPARISON & RANGES
// ============================================================================
/**
* Compares two periods
* @param a - First period
* @param b - Second period
* @returns -1 if a < b, 0 if equal, 1 if a > b
*/
export function comparePeriods(a: string, b: string): number {
if (a === b) return 0;
return a < b ? -1 : 1;
}
/**
* Builds a range of periods between start and end (inclusive)
* @param start - Start period
* @param end - End period
* @returns Array of period strings
*/
export function buildPeriodRange(start: string, end: string): string[] {
const [startKey, endKey] =
comparePeriods(start, end) <= 0 ? [start, end] : [end, start];
const startParts = parsePeriod(startKey);
const endParts = parsePeriod(endKey);
const items: string[] = [];
let currentYear = startParts.year;
let currentMonth = startParts.month;
while (
currentYear < endParts.year ||
(currentYear === endParts.year && currentMonth <= endParts.month)
) {
items.push(formatPeriod(currentYear, currentMonth));
if (currentYear === endParts.year && currentMonth === endParts.month) {
break;
}
currentMonth += 1;
if (currentMonth > 12) {
currentMonth = 1;
currentYear += 1;
}
}
return items;
}
// ============================================================================
// URL PARAM HANDLING (mes-ano format for Portuguese URLs)
// ============================================================================
const MONTH_MAP = new Map(MONTH_NAMES.map((name, index) => [name, index]));
const normalize = (value: string | null | undefined) =>
(value ?? "").trim().toLowerCase();
export type ParsedPeriod = {
period: string;
monthName: string;
year: number;
};
/**
* Parses URL param in "mes-ano" format (e.g., "novembro-2025")
* @param periodParam - URL parameter string
* @param referenceDate - Fallback date if param is invalid
* @returns Parsed period object
*/
export function parsePeriodParam(
periodParam: string | null | undefined,
referenceDate = new Date()
): ParsedPeriod {
const fallbackMonthIndex = referenceDate.getMonth();
const fallbackYear = referenceDate.getFullYear();
const fallbackPeriod = formatPeriod(fallbackYear, fallbackMonthIndex + 1);
if (!periodParam) {
const monthName = MONTH_NAMES[fallbackMonthIndex];
return { period: fallbackPeriod, monthName, year: fallbackYear };
}
const [rawMonth, rawYear] = periodParam.split("-");
const normalizedMonth = normalize(rawMonth);
const monthIndex = MONTH_MAP.get(normalizedMonth);
const parsedYear = Number.parseInt(rawYear ?? "", 10);
if (monthIndex === undefined || Number.isNaN(parsedYear)) {
const monthName = MONTH_NAMES[fallbackMonthIndex];
return { period: fallbackPeriod, monthName, year: fallbackYear };
}
const monthName = MONTH_NAMES[monthIndex];
return {
period: formatPeriod(parsedYear, monthIndex + 1),
monthName,
year: parsedYear,
};
}
/**
* Formats month name and year to URL param format
* @param monthName - Month name in Portuguese
* @param year - Year number
* @returns URL param string in "mes-ano" format
*/
export function formatPeriodParam(monthName: string, year: number): string {
return `${normalize(monthName)}-${year}`;
}
/**
* Converts period from YYYY-MM format to URL param format
* @example
* formatPeriodForUrl("2025-11") // "novembro-2025"
* formatPeriodForUrl("2025-01") // "janeiro-2025"
*/
export function formatPeriodForUrl(period: string): string {
const [yearStr, monthStr] = period.split("-");
const year = Number.parseInt(yearStr ?? "", 10);
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1;
if (
Number.isNaN(year) ||
Number.isNaN(monthIndex) ||
monthIndex < 0 ||
monthIndex > 11
) {
return period;
}
const monthName = MONTH_NAMES[monthIndex] ?? "";
return formatPeriodParam(monthName, year);
}
// ============================================================================
// DISPLAY FORMATTING
// ============================================================================
function capitalize(value: string): string {
return value.length > 0
? value[0]?.toUpperCase().concat(value.slice(1))
: value;
}
/**
* Formats period for display in Portuguese
* @example
* displayPeriod("2025-11") // "Novembro de 2025"
*/
export function displayPeriod(period: string): string {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1] ?? "";
return `${capitalize(monthName)} de ${year}`;
}
/**
* Alias for displayPeriod - formats period for display
* @example
* formatMonthLabel("2024-01") // "Janeiro de 2024"
*/
export function formatMonthLabel(period: string): string {
return displayPeriod(period);
}
// ============================================================================
// DATE DERIVATION
// ============================================================================
/**
* Derives a period (YYYY-MM) from a date string or current date
* @example
* derivePeriodFromDate("2024-01-15") // "2024-01"
* derivePeriodFromDate() // current period
*/
export function derivePeriodFromDate(value?: string | null): string {
const date = value ? new Date(value) : new Date();
if (Number.isNaN(date.getTime())) {
return getCurrentPeriod();
}
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
}
// ============================================================================
// SELECT OPTIONS GENERATION
// ============================================================================
export type SelectOption = {
value: string;
label: string;
};
/**
* Creates month options for a select dropdown, centered around current month
* @param currentValue - Current period value to ensure it's included in options
* @param offsetRange - Number of months before/after current month (default: 3)
* @returns Array of select options with formatted labels
*/
export function createMonthOptions(
currentValue?: string,
offsetRange: number = 3
): SelectOption[] {
const now = new Date();
const options: SelectOption[] = [];
for (let offset = -offsetRange; offset <= offsetRange; offset += 1) {
const date = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const value = formatPeriod(date.getFullYear(), date.getMonth() + 1);
options.push({ value, label: displayPeriod(value) });
}
// Include current value if not already in options
if (currentValue && !options.some((option) => option.value === currentValue)) {
options.push({
value: currentValue,
label: displayPeriod(currentValue),
});
}
return options.sort((a, b) => a.value.localeCompare(b.value));
}

43
lib/utils/string.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Utility functions for string normalization and manipulation
*/
/**
* Normalizes optional string - trims and returns null if empty
* @param value - String to normalize
* @returns Trimmed string or null if empty
*/
export function normalizeOptionalString(
value: string | null | undefined
): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
/**
* Normalizes file path by extracting filename
* @param path - File path to normalize
* @returns Filename without path
*/
export function normalizeFilePath(path: string | null | undefined): string {
return path?.split("/").filter(Boolean).pop() ?? "";
}
/**
* Normalizes whitespace in string (replaces multiple spaces with single space)
* @param value - String to normalize
* @returns String with normalized whitespace
*/
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
/**
* Normalizes icon input - trims and returns null if empty
* @param icon - Icon string to normalize
* @returns Trimmed icon string or null
*/
export function normalizeIconInput(icon?: string | null): string | null {
const trimmed = icon?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}

17
lib/utils/ui.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* UI utilities - Functions for UI manipulation and styling
*
* This module contains UI-related utilities, primarily for className manipulation.
*/
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merges Tailwind CSS classes with proper override handling
* @param inputs - Class values to merge
* @returns Merged className string
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}