mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-03-10 04:51:47 +00:00
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:
21
lib/accounts/constants.ts
Normal file
21
lib/accounts/constants.ts
Normal 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
110
lib/actions/helpers.ts
Normal 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
27
lib/actions/types.ts
Normal 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
15
lib/auth/client.ts
Normal 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
119
lib/auth/config.ts
Normal 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
69
lib/auth/server.ts
Normal 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() });
|
||||
}
|
||||
14
lib/categorias/constants.ts
Normal file
14
lib/categorias/constants.ts
Normal 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",
|
||||
};
|
||||
90
lib/categorias/defaults.ts
Normal file
90
lib/categorias/defaults.ts
Normal 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
172
lib/categorias/icons.ts
Normal 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
113
lib/dashboard/accounts.ts
Normal 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
106
lib/dashboard/boletos.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
131
lib/dashboard/categories/category-details.ts
Normal file
131
lib/dashboard/categories/category-details.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
163
lib/dashboard/categories/expenses-by-category.ts
Normal file
163
lib/dashboard/categories/expenses-by-category.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
147
lib/dashboard/categories/income-by-category.ts
Normal file
147
lib/dashboard/categories/income-by-category.ts
Normal 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
13
lib/dashboard/common.ts
Normal 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;
|
||||
96
lib/dashboard/expenses/installment-expenses.ts
Normal file
96
lib/dashboard/expenses/installment-expenses.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
lib/dashboard/expenses/recurring-expenses.ts
Normal file
66
lib/dashboard/expenses/recurring-expenses.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
84
lib/dashboard/expenses/top-expenses.ts
Normal file
84
lib/dashboard/expenses/top-expenses.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
82
lib/dashboard/fetch-dashboard-data.ts
Normal file
82
lib/dashboard/fetch-dashboard-data.ts
Normal 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>>;
|
||||
138
lib/dashboard/income-expense-balance.ts
Normal file
138
lib/dashboard/income-expense-balance.ts
Normal 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
279
lib/dashboard/invoices.ts
Normal 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
155
lib/dashboard/metrics.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
373
lib/dashboard/notifications.ts
Normal file
373
lib/dashboard/notifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
lib/dashboard/payments/payment-conditions.ts
Normal file
79
lib/dashboard/payments/payment-conditions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
lib/dashboard/payments/payment-methods.ts
Normal file
79
lib/dashboard/payments/payment-methods.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
120
lib/dashboard/payments/payment-status.ts
Normal file
120
lib/dashboard/payments/payment-status.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
145
lib/dashboard/purchases-by-category.ts
Normal file
145
lib/dashboard/purchases-by-category.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
71
lib/dashboard/recent-transactions.ts
Normal file
71
lib/dashboard/recent-transactions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
lib/dashboard/top-establishments.ts
Normal file
86
lib/dashboard/top-establishments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
192
lib/dashboard/widgets/widgets-config.tsx
Normal file
192
lib/dashboard/widgets/widgets-config.tsx
Normal 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
39
lib/db.ts
Normal 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
32
lib/faturas.ts
Normal 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}$/;
|
||||
143
lib/installments/anticipation-helpers.ts
Normal file
143
lib/installments/anticipation-helpers.ts
Normal 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}`;
|
||||
}
|
||||
66
lib/installments/anticipation-types.ts
Normal file
66
lib/installments/anticipation-types.ts
Normal 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
79
lib/installments/utils.ts
Normal 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}`;
|
||||
}
|
||||
83
lib/lancamentos/categoria-helpers.ts
Normal file
83
lib/lancamentos/categoria-helpers.ts
Normal 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);
|
||||
}
|
||||
15
lib/lancamentos/constants.ts
Normal file
15
lib/lancamentos/constants.ts
Normal 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;
|
||||
192
lib/lancamentos/form-helpers.ts
Normal file
192
lib/lancamentos/form-helpers.ts
Normal 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;
|
||||
}
|
||||
94
lib/lancamentos/formatting-helpers.ts
Normal file
94
lib/lancamentos/formatting-helpers.ts
Normal 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);
|
||||
}
|
||||
521
lib/lancamentos/page-helpers.ts
Normal file
521
lib/lancamentos/page-helpers.ts
Normal 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
40
lib/logo/index.ts
Normal 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
30
lib/logo/options.ts
Normal 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
114
lib/pagadores/access.ts
Normal 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);
|
||||
}
|
||||
13
lib/pagadores/constants.ts
Normal file
13
lib/pagadores/constants.ts
Normal 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
60
lib/pagadores/defaults.ts
Normal 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
325
lib/pagadores/details.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
237
lib/pagadores/notifications.ts
Normal file
237
lib/pagadores/notifications.ts
Normal 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
65
lib/pagadores/utils.ts
Normal 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
134
lib/schemas/common.ts
Normal 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
69
lib/schemas/insights.ts
Normal 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>;
|
||||
5
lib/transferencias/constants.ts
Normal file
5
lib/transferencias/constants.ts
Normal 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
168
lib/utils/calculator.ts
Normal 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
60
lib/utils/currency.ts
Normal 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
235
lib/utils/date.ts
Normal 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
61
lib/utils/icons.tsx
Normal 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
51
lib/utils/math.ts
Normal 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
74
lib/utils/number.ts
Normal 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
381
lib/utils/period/index.ts
Normal 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
43
lib/utils/string.ts
Normal 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
17
lib/utils/ui.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user