refactor: migrate from ESLint to Biome and extract SQL queries to data.ts

- Replace ESLint with Biome for linting and formatting
- Configure Biome with tabs, double quotes, and organized imports
- Move all SQL/Drizzle queries from page.tsx files to data.ts files
- Create new data.ts files for: ajustes, dashboard, relatorios/categorias
- Update existing data.ts files: extrato, fatura (add lancamentos queries)
- Remove all drizzle-orm imports from page.tsx files
- Update README.md with new tooling info

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-01-27 13:15:37 +00:00
parent 8ffe61c59b
commit a7f63fb77a
442 changed files with 66141 additions and 69292 deletions

View File

@@ -1,21 +1,21 @@
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
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";
LANCAMENTO_CONDITIONS.find((condition) => condition === "À vista") ??
"À vista";
export const INITIAL_BALANCE_PAYMENT_METHOD =
LANCAMENTO_PAYMENT_METHODS.find((method) => method === "Pix") ?? "Pix";
LANCAMENTO_PAYMENT_METHODS.find((method) => method === "Pix") ?? "Pix";
export const INITIAL_BALANCE_TRANSACTION_TYPE =
LANCAMENTO_TRANSACTION_TYPES.find((type) => type === "Receita") ?? "Receita";
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}`;
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`;

View File

@@ -1,6 +1,6 @@
import { getUser } from "@/lib/auth/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getUser } from "@/lib/auth/server";
import type { ActionResult } from "./types";
import { errorResult } from "./types";
@@ -10,29 +10,29 @@ import { errorResult } from "./types";
* @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 z.ZodError) {
return errorResult(error.issues[0]?.message ?? "Dados inválidos.");
}
if (error instanceof Error) {
return errorResult(error.message);
}
if (error instanceof Error) {
return errorResult(error.message);
}
return errorResult("Erro inesperado.");
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", "/anotacoes/arquivadas"],
lancamentos: ["/lancamentos", "/contas"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
cartoes: ["/cartoes"],
contas: ["/contas", "/lancamentos"],
categorias: ["/categorias"],
orcamentos: ["/orcamentos"],
pagadores: ["/pagadores"],
anotacoes: ["/anotacoes", "/anotacoes/arquivadas"],
lancamentos: ["/lancamentos", "/contas"],
inbox: ["/pre-lancamentos", "/lancamentos", "/dashboard"],
} as const;
/**
@@ -40,19 +40,19 @@ export const revalidateConfig = {
* @param entity - The entity type
*/
export function revalidateForEntity(
entity: keyof typeof revalidateConfig
entity: keyof typeof revalidateConfig,
): void {
revalidateConfig[entity].forEach((path) => revalidatePath(path));
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;
/** Paths to revalidate after successful execution */
revalidatePaths?: string[];
/** Entity to revalidate (uses predefined config) */
revalidateEntity?: keyof typeof revalidateConfig;
}
/**
@@ -76,36 +76,40 @@ interface ActionHandlerOptions {
* ```
*/
export function createActionHandler<TInput, TResult = string>(
schema: z.ZodSchema<TInput>,
handler: (data: TInput, userId: string) => Promise<TResult>,
options?: ActionHandlerOptions
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();
return async (input: unknown): Promise<ActionResult<TResult>> => {
try {
// Get authenticated user
const user = await getUser();
// Validate input
const data = schema.parse(input);
// Validate input
const data = schema.parse(input);
// Execute handler
const result = await handler(data, user.id);
// 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));
}
// 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 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);
}
};
return {
success: true,
message: "Operação realizada com sucesso.",
data: result,
};
} catch (error) {
return handleActionError(error);
}
};
}

View File

@@ -2,22 +2,22 @@
* Standard action result type
*/
export type ActionResult<TData = void> =
| { success: true; message: string; data?: TData }
| { success: false; error: string };
| { success: true; message: string; data?: TData }
| { success: false; error: string };
/**
* Success result helper
*/
export function successResult<TData = void>(
message: string,
data?: TData
message: string,
data?: TData,
): ActionResult<TData> {
return { success: true, message, data };
return { success: true, message, data };
}
/**
* Error result helper
*/
export function errorResult(error: string): ActionResult {
return { success: false, error };
return { success: false, error };
}

View File

@@ -4,9 +4,10 @@
* Handles JWT generation, validation, and token hashing for device authentication.
*/
import crypto from "crypto";
import crypto from "node:crypto";
const JWT_SECRET = process.env.BETTER_AUTH_SECRET || "opensheets-secret-change-me";
const JWT_SECRET =
process.env.BETTER_AUTH_SECRET || "opensheets-secret-change-me";
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
@@ -15,19 +16,19 @@ const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
// ============================================================================
export interface JwtPayload {
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
sub: string; // userId
type: "api_access" | "api_refresh";
tokenId: string;
deviceId?: string;
iat: number;
exp: number;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
accessToken: string;
refreshToken: string;
tokenId: string;
expiresAt: Date;
}
// ============================================================================
@@ -38,56 +39,59 @@ export interface TokenPair {
* Base64URL encode a string
*/
function base64UrlEncode(str: string): string {
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return Buffer.from(str)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Base64URL decode a string
*/
function base64UrlDecode(str: string): string {
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
str = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = str.length % 4;
if (pad) {
str += "=".repeat(4 - pad);
}
return Buffer.from(str, "base64").toString();
}
/**
* Create HMAC-SHA256 signature
*/
function createSignature(data: string): string {
return crypto
.createHmac("sha256", JWT_SECRET)
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return crypto
.createHmac("sha256", JWT_SECRET)
.update(data)
.digest("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Create a JWT token
*/
export function createJwt(payload: Omit<JwtPayload, "iat" | "exp">, expiresIn: number): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
export function createJwt(
payload: Omit<JwtPayload, "iat" | "exp">,
expiresIn: number,
): string {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const fullPayload: JwtPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
const headerEncoded = base64UrlEncode(JSON.stringify(header));
const payloadEncoded = base64UrlEncode(JSON.stringify(fullPayload));
const signature = createSignature(`${headerEncoded}.${payloadEncoded}`);
return `${headerEncoded}.${payloadEncoded}.${signature}`;
return `${headerEncoded}.${payloadEncoded}.${signature}`;
}
/**
@@ -95,30 +99,37 @@ export function createJwt(payload: Omit<JwtPayload, "iat" | "exp">, expiresIn: n
* @returns The decoded payload or null if invalid
*/
export function verifyJwt(token: string): JwtPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(`${headerEncoded}.${payloadEncoded}`);
const [headerEncoded, payloadEncoded, signature] = parts;
const expectedSignature = createSignature(
`${headerEncoded}.${payloadEncoded}`,
);
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
return null;
}
// Constant-time comparison to prevent timing attacks
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
)
) {
return null;
}
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
// Check expiration
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) {
return null;
}
return payload;
} catch {
return null;
}
return payload;
} catch {
return null;
}
}
// ============================================================================
@@ -129,76 +140,86 @@ export function verifyJwt(token: string): JwtPayload | null {
* Generate a random token ID
*/
export function generateTokenId(): string {
return crypto.randomUUID();
return crypto.randomUUID();
}
/**
* Generate a random API token with prefix
*/
export function generateApiToken(): string {
const randomPart = crypto.randomBytes(32).toString("base64url");
return `os_${randomPart}`;
const randomPart = crypto.randomBytes(32).toString("base64url");
return `os_${randomPart}`;
}
/**
* Hash a token using SHA-256
*/
export function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Get the display prefix of a token (first 8 chars after prefix)
*/
export function getTokenPrefix(token: string): string {
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
// Remove "os_" prefix and get first 8 chars
const withoutPrefix = token.replace(/^os_/, "");
return `os_${withoutPrefix.substring(0, 8)}...`;
}
/**
* Generate a complete token pair (access + refresh)
*/
export function generateTokenPair(userId: string, deviceId?: string): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
export function generateTokenPair(
userId: string,
deviceId?: string,
): TokenPair {
const tokenId = generateTokenId();
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY
);
const accessToken = createJwt(
{ sub: userId, type: "api_access", tokenId, deviceId },
ACCESS_TOKEN_EXPIRY,
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY
);
const refreshToken = createJwt(
{ sub: userId, type: "api_refresh", tokenId, deviceId },
REFRESH_TOKEN_EXPIRY,
);
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
return {
accessToken,
refreshToken,
tokenId,
expiresAt,
};
}
/**
* Refresh an access token using a refresh token
*/
export function refreshAccessToken(refreshToken: string): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
export function refreshAccessToken(
refreshToken: string,
): { accessToken: string; expiresAt: Date } | null {
const payload = verifyJwt(refreshToken);
if (!payload || payload.type !== "api_refresh") {
return null;
}
if (!payload || payload.type !== "api_refresh") {
return null;
}
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_EXPIRY * 1000);
const accessToken = createJwt(
{ sub: payload.sub, type: "api_access", tokenId: payload.tokenId, deviceId: payload.deviceId },
ACCESS_TOKEN_EXPIRY
);
const accessToken = createJwt(
{
sub: payload.sub,
type: "api_access",
tokenId: payload.tokenId,
deviceId: payload.deviceId,
},
ACCESS_TOKEN_EXPIRY,
);
return { accessToken, expiresAt };
return { accessToken, expiresAt };
}
// ============================================================================
@@ -209,9 +230,9 @@ export function refreshAccessToken(refreshToken: string): { accessToken: string;
* Extract bearer token from Authorization header
*/
export function extractBearerToken(authHeader: string | null): string | null {
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
if (!authHeader) return null;
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}
/**
@@ -219,20 +240,23 @@ export function extractBearerToken(authHeader: string | null): string | null {
* @deprecated Use validateHashToken for os_xxx tokens
*/
export function validateApiToken(token: string): JwtPayload | null {
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return null;
}
return payload;
const payload = verifyJwt(token);
if (!payload || payload.type !== "api_access") {
return null;
}
return payload;
}
/**
* Validate a hash-based API token (os_xxx format)
* Returns the token hash for database lookup
*/
export function validateHashToken(token: string): { valid: boolean; tokenHash?: string } {
if (!token || !token.startsWith("os_")) {
return { valid: false };
}
return { valid: true, tokenHash: hashToken(token) };
export function validateHashToken(token: string): {
valid: boolean;
tokenHash?: string;
} {
if (!token || !token.startsWith("os_")) {
return { valid: false };
}
return { valid: true, tokenHash: hashToken(token) };
}

View File

@@ -3,7 +3,7 @@ import { createAuthClient } from "better-auth/react";
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
export const authClient = createAuthClient({
...(baseURL ? { baseURL } : {}),
...(baseURL ? { baseURL } : {}),
});
/**

View File

@@ -5,13 +5,13 @@
* Suporta email/password e Google OAuth.
*/
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { GoogleProfile } from "better-auth/social-providers";
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
@@ -28,20 +28,20 @@ const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
* 4. "Usuário" (fallback final)
*/
function getNameFromGoogleProfile(profile: GoogleProfile): string {
const fullName = profile.name?.trim();
if (fullName) return fullName;
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 fromGivenFamily = [profile.given_name, profile.family_name]
.filter(Boolean)
.join(" ")
.trim();
if (fromGivenFamily) return fromGivenFamily;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
const fromEmail = profile.email
? normalizeNameFromEmail(profile.email)
: undefined;
return fromEmail ?? "Usuário";
return fromEmail ?? "Usuário";
}
// ============================================================================
@@ -49,99 +49,99 @@ function getNameFromGoogleProfile(profile: GoogleProfile): string {
// ============================================================================
export const auth = betterAuth({
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Trust host configuration for production environments
trustedOrigins: process.env.BETTER_AUTH_URL
? [process.env.BETTER_AUTH_URL]
: [],
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Email/Password authentication
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Database adapter (Drizzle + PostgreSQL)
database: drizzleAdapter(db, {
provider: "pg",
schema,
camelCase: true,
}),
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Session configuration - Safari compatibility
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// Advanced configuration for Safari compatibility
advanced: {
cookieOptions: {
sameSite: "lax", // Safari compatible
secure: process.env.NODE_ENV === "production", // HTTPS in production only
httpOnly: true,
},
crossSubDomainCookies: {
enabled: false, // Disable for better Safari compatibility
},
},
// 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,
// 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
}
},
},
},
},
// 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."
);
console.warn(
"[Auth] Google OAuth não configurado. Defina GOOGLE_CLIENT_ID e GOOGLE_CLIENT_SECRET.",
);
}

View File

@@ -10,9 +10,9 @@
* to /login if the user is not authenticated.
*/
import { auth } from "@/lib/auth/config";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth/config";
/**
* Gets the current authenticated user
@@ -20,13 +20,13 @@ import { redirect } from "next/navigation";
* @throws Redirects to /login if user is not authenticated
*/
export async function getUser() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session.user;
return session.user;
}
/**
@@ -35,13 +35,13 @@ export async function getUser() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserId() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session.user.id;
return session.user.id;
}
/**
@@ -50,13 +50,13 @@ export async function getUserId() {
* @throws Redirects to /login if user is not authenticated
*/
export async function getUserSession() {
const session = await auth.api.getSession({ headers: await headers() });
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/login");
}
if (!session?.user) {
redirect("/login");
}
return session;
return session;
}
/**
@@ -65,5 +65,5 @@ export async function getUserSession() {
* @note This function does not redirect if user is not authenticated
*/
export async function getOptionalUserSession() {
return auth.api.getSession({ headers: await headers() });
return auth.api.getSession({ headers: await headers() });
}

View File

@@ -3,6 +3,6 @@ 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",
receita: "Receita",
despesa: "Despesa",
};

View File

@@ -5,56 +5,56 @@
* - /lib/category-defaults.ts
*/
import { eq } from "drizzle-orm";
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;
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" },
// 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" },
// 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",
},
// Categoria especial para transferências entre contas
{
name: "Transferência interna",
type: "receita",
icon: "RiArrowLeftRightLine",
},
];
/**
@@ -62,29 +62,29 @@ export const DEFAULT_CATEGORIES: DefaultCategory[] = [
* @param userId - User ID to seed categories for
*/
export async function seedDefaultCategoriesForUser(userId: string | undefined) {
if (!userId) {
return;
}
if (!userId) {
return;
}
const existing = await db.query.categorias.findFirst({
columns: { id: true },
where: eq(categorias.userId, userId),
});
const existing = await db.query.categorias.findFirst({
columns: { id: true },
where: eq(categorias.userId, userId),
});
if (existing) {
return;
}
if (existing) {
return;
}
if (DEFAULT_CATEGORIES.length === 0) {
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,
}))
);
await db.insert(categorias).values(
DEFAULT_CATEGORIES.map((category) => ({
name: category.name,
type: category.type,
icon: category.icon,
userId,
})),
);
}

View File

@@ -6,154 +6,154 @@
*/
export type CategoryIconOption = {
label: string;
value: string;
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: "RiBankCard2Line" },
{ 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" },
// Finanças
{ label: "Dinheiro", value: "RiMoneyDollarCircleLine" },
{ label: "Carteira", value: "RiWallet3Line" },
{ label: "Carteira 2", value: "RiWalletLine" },
{ label: "Cartão", value: "RiBankCard2Line" },
{ 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" },
// 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" },
{ label: "Cerveja", value: "RiBeerLine" },
// Alimentação
{ label: "Restaurante", value: "RiRestaurantLine" },
{ label: "Garfo e faca", value: "RiRestaurant2Line" },
{ label: "Café", value: "RiCupLine" },
{ label: "Bebida", value: "RiDrinksFill" },
{ label: "Pizza", value: "RiCake3Line" },
{ label: "Cerveja", value: "RiBeerLine" },
// 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" },
// 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" },
// 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" },
// 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" },
// 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" },
// 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" },
// 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" },
// 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" },
// Pessoas
{ label: "Usuário", value: "RiUserLine" },
{ label: "Grupo", value: "RiGroupLine" },
{ label: "Família", value: "RiParentLine" },
{ label: "Bebê", value: "RiBabyCarriageLine" },
// Animais
{ label: "Pet", value: "RiBearSmileLine" },
// Animais
{ label: "Pet", value: "RiBearSmileLine" },
// Vestuário
{ label: "Camiseta", value: "RiTShirtLine" },
// Vestuário
{ label: "Camiseta", value: "RiTShirtLine" },
// Documentos
{ label: "Arquivo", value: "RiFileTextLine" },
{ label: "Documento", value: "RiArticleLine" },
{ label: "Balança", value: "RiScales2Line" },
{ label: "Escudo", value: "RiShieldCheckLine" },
// 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" },
// 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" },
// 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" },
];
/**
@@ -161,7 +161,7 @@ export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
* @returns Array of icon options
*/
export function getCategoryIconOptions() {
return CATEGORY_ICON_OPTIONS;
return CATEGORY_ICON_OPTIONS;
}
/**
@@ -169,5 +169,5 @@ export function getCategoryIconOptions() {
* @returns Default icon value
*/
export function getDefaultIconForType() {
return CATEGORY_ICON_OPTIONS[0]?.value ?? "";
return CATEGORY_ICON_OPTIONS[0]?.value ?? "";
}

View File

@@ -1,49 +1,49 @@
import { and, eq, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
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;
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;
id: string;
name: string;
accountType: string;
status: string;
logo: string | null;
initialBalance: number;
balance: number;
excludeFromBalance: boolean;
};
export type DashboardAccountsSnapshot = {
totalBalance: number;
accounts: DashboardAccount[];
totalBalance: number;
accounts: DashboardAccount[];
};
export async function fetchDashboardAccounts(
userId: string
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>`
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
@@ -54,60 +54,61 @@ export async function fetchDashboardAccounts(
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
);
})
.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);
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);
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);
const totalBalance = accounts
.filter((account) => !account.excludeFromBalance)
.reduce((total, account) => total + account.balance, 0);
return {
totalBalance,
accounts,
};
return {
totalBalance,
accounts,
};
}

View File

@@ -1,106 +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";
import { lancamentos, pagadores } from "@/db/schema";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
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;
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;
id: string;
name: string;
amount: number;
dueDate: string | null;
boletoPaymentDate: string | null;
isSettled: boolean;
};
export type DashboardBoletosSnapshot = {
boletos: DashboardBoleto[];
totalPendingAmount: number;
pendingCount: number;
boletos: DashboardBoleto[];
totalPendingAmount: number;
pendingCount: number;
};
const toISODate = (value: Date | string | null) => {
if (!value) {
return null;
}
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value;
}
if (typeof value === "string") {
return value;
}
return null;
return null;
};
export async function fetchDashboardBoletos(
userId: string,
period: string
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 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),
};
});
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;
let totalPendingAmount = 0;
let pendingCount = 0;
for (const boleto of boletos) {
if (!boleto.isSettled) {
totalPendingAmount += boleto.amount;
pendingCount += 1;
}
}
for (const boleto of boletos) {
if (!boleto.isSettled) {
totalPendingAmount += boleto.amount;
pendingCount += 1;
}
}
return {
boletos,
totalPendingAmount,
pendingCount,
};
return {
boletos,
totalPendingAmount,
pendingCount,
};
}

View File

@@ -1,148 +1,152 @@
import { categorias, lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { and, desc, eq, isNull, ne, or, sql } from "drizzle-orm";
import { categorias, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} 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, ne } 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;
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
current: number,
previous: number,
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
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;
}
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;
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;
// 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
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)),
});
const category = await db.query.categorias.findFirst({
where: and(eq(categorias.userId, userId), eq(categorias.id, categoryId)),
});
if (!category) {
return null;
}
if (!category) {
return null;
}
const previousPeriod = getPreviousPeriod(period);
const transactionType = category.type === "receita" ? "Receita" : "Despesa";
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 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 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) => {
// Filtrar apenas pagadores admin
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
const filteredRows = currentRows.filter((row) => {
// Filtrar apenas pagadores admin
if (row.pagador?.role !== PAGADOR_ROLE_ADMIN) return false;
// Excluir saldos iniciais se a conta tiver o flag ativo
if (row.note === INITIAL_BALANCE_NOTE && row.conta?.excludeInitialBalanceFromIncome) {
return false;
}
// Excluir saldos iniciais se a conta tiver o flag ativo
if (
row.note === INITIAL_BALANCE_NOTE &&
row.conta?.excludeInitialBalanceFromIncome
) {
return false;
}
return true;
}
);
return true;
});
const transactions = mapLancamentosData(filteredRows);
const transactions = mapLancamentosData(filteredRows);
const currentTotal = transactions.reduce(
(total, transaction) => total + Math.abs(toNumber(transaction.amount)),
0
);
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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
);
const [previousTotalRow] = await db
.select({
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.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),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
);
const previousTotal = Math.abs(toNumber(previousTotalRow?.total ?? 0));
const percentageChange = calculatePercentageChange(
currentTotal,
previousTotal
);
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,
};
return {
category: {
id: category.id,
name: category.name,
icon: category.icon,
type: category.type as CategoryType,
},
period,
previousPeriod,
currentTotal,
previousTotal,
percentageChange,
transactions,
};
}

View File

@@ -1,60 +1,60 @@
import { db } from "@/lib/db";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { toNumber } from "@/lib/dashboard/common";
import { addMonths, format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type CategoryOption = {
id: string;
name: string;
icon: string | null;
type: "receita" | "despesa";
id: string;
name: string;
icon: string | null;
type: "receita" | "despesa";
};
export type CategoryHistoryItem = {
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
};
export type CategoryHistoryData = {
months: string[]; // ["NOV", "DEZ", "JAN", ...]
categories: CategoryHistoryItem[];
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: CategoryOption[];
months: string[]; // ["NOV", "DEZ", "JAN", ...]
categories: CategoryHistoryItem[];
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: CategoryOption[];
};
const CHART_COLORS = [
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
"#ef4444", // red-500
"#3b82f6", // blue-500
"#10b981", // emerald-500
"#f59e0b", // amber-500
"#8b5cf6", // violet-500
];
export async function fetchAllCategories(
userId: string
userId: string,
): Promise<CategoryOption[]> {
const result = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
const result = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
return result as CategoryOption[];
return result as CategoryOption[];
}
/**
@@ -62,141 +62,141 @@ export async function fetchAllCategories(
* Widget will allow user to select up to 5 to display
*/
export async function fetchCategoryHistory(
userId: string,
currentPeriod: string
userId: string,
currentPeriod: string,
): Promise<CategoryHistoryData> {
// Generate last 8 months, current month, and next month (10 total)
const periods: string[] = [];
const monthLabels: string[] = [];
// Generate last 8 months, current month, and next month (10 total)
const periods: string[] = [];
const monthLabels: string[] = [];
const [year, month] = currentPeriod.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
const [year, month] = currentPeriod.split("-").map(Number);
const currentDate = new Date(year, month - 1, 1);
// Generate months from -8 to +1 (relative to current)
for (let i = 8; i >= -1; i--) {
const date = addMonths(currentDate, -i);
const period = format(date, "yyyy-MM");
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
periods.push(period);
monthLabels.push(label);
}
// Generate months from -8 to +1 (relative to current)
for (let i = 8; i >= -1; i--) {
const date = addMonths(currentDate, -i);
const period = format(date, "yyyy-MM");
const label = format(date, "MMM", { locale: ptBR }).toUpperCase();
periods.push(period);
monthLabels.push(label);
}
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch all categories for the selector
const allCategories = await fetchAllCategories(userId);
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
"total_amount"
),
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(categorias.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
)
)
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period
);
// Fetch monthly data for ALL categories with transactions
const monthlyDataQuery = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
period: lancamentos.period,
totalAmount: sql<string>`SUM(ABS(${lancamentos.amount}))`.as(
"total_amount",
),
})
.from(lancamentos)
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(categorias.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
isNull(lancamentos.note),
sql`${
lancamentos.note
} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
),
)
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
lancamentos.period,
);
if (monthlyDataQuery.length === 0) {
return {
months: monthLabels,
categories: [],
chartData: monthLabels.map((month) => ({ month })),
allCategories,
};
}
if (monthlyDataQuery.length === 0) {
return {
months: monthLabels,
categories: [],
chartData: monthLabels.map((month) => ({ month })),
allCategories,
};
}
// Get unique categories from query results
const uniqueCategories = Array.from(
new Map(
monthlyDataQuery.map((row) => [
row.categoryId,
{
id: row.categoryId,
name: row.categoryName,
icon: row.categoryIcon,
},
])
).values()
);
// Get unique categories from query results
const uniqueCategories = Array.from(
new Map(
monthlyDataQuery.map((row) => [
row.categoryId,
{
id: row.categoryId,
name: row.categoryName,
icon: row.categoryIcon,
},
]),
).values(),
);
// Transform data into chart-ready format
const categoriesMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}
>();
// Transform data into chart-ready format
const categoriesMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
color: string;
data: Record<string, number>;
}
>();
// Initialize ALL categories with transactions with all months set to 0
uniqueCategories.forEach((cat, index) => {
const monthData: Record<string, number> = {};
periods.forEach((period, periodIndex) => {
monthData[monthLabels[periodIndex]] = 0;
});
// Initialize ALL categories with transactions with all months set to 0
uniqueCategories.forEach((cat, index) => {
const monthData: Record<string, number> = {};
periods.forEach((_period, periodIndex) => {
monthData[monthLabels[periodIndex]] = 0;
});
categoriesMap.set(cat.id, {
id: cat.id,
name: cat.name,
icon: cat.icon,
color: CHART_COLORS[index % CHART_COLORS.length],
data: monthData,
});
});
categoriesMap.set(cat.id, {
id: cat.id,
name: cat.name,
icon: cat.icon,
color: CHART_COLORS[index % CHART_COLORS.length],
data: monthData,
});
});
// Fill in actual values from monthly data
monthlyDataQuery.forEach((row) => {
const category = categoriesMap.get(row.categoryId);
if (category) {
const periodIndex = periods.indexOf(row.period);
if (periodIndex !== -1) {
const monthLabel = monthLabels[periodIndex];
category.data[monthLabel] = toNumber(row.totalAmount);
}
}
});
// Fill in actual values from monthly data
monthlyDataQuery.forEach((row) => {
const category = categoriesMap.get(row.categoryId);
if (category) {
const periodIndex = periods.indexOf(row.period);
if (periodIndex !== -1) {
const monthLabel = monthLabels[periodIndex];
category.data[monthLabel] = toNumber(row.totalAmount);
}
}
});
// Convert to chart data format
const chartData = monthLabels.map((month) => {
const dataPoint: Record<string, number | string> = { month };
// Convert to chart data format
const chartData = monthLabels.map((month) => {
const dataPoint: Record<string, number | string> = { month };
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
categoriesMap.forEach((category) => {
dataPoint[category.name] = category.data[month];
});
return dataPoint;
});
return dataPoint;
});
return {
months: monthLabels,
categories: Array.from(categoriesMap.values()),
chartData,
allCategories,
};
return {
months: monthLabels,
categories: Array.from(categoriesMap.values()),
chartData,
allCategories,
};
}

View File

@@ -1,163 +1,168 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, orcamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
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;
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;
categories: CategoryExpenseItem[];
currentTotal: number;
previousTotal: number;
};
const calculatePercentageChange = (
current: number,
previous: number
current: number,
previous: number,
): number | null => {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
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;
}
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;
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;
// 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
userId: string,
period: string,
): Promise<ExpensesByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
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 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);
// 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;
// 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;
}
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));
}
// 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;
// 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;
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,
};
});
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);
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return {
categories,
currentTotal,
previousTotal,
};
}

View File

@@ -1,161 +1,177 @@
import { categorias, lancamentos, orcamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import {
categorias,
contas,
lancamentos,
orcamentos,
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 { getPreviousPeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
import { and, eq, isNull, or, sql, ne } from "drizzle-orm";
import { getPreviousPeriod } from "@/lib/utils/period";
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;
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;
categories: CategoryIncomeItem[];
currentTotal: number;
previousTotal: number;
};
export async function fetchIncomeByCategory(
userId: string,
period: string
userId: string,
period: string,
): Promise<IncomeByCategoryData> {
const previousPeriod = getPreviousPeriod(period);
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(contas, eq(lancamentos.contaId, contas.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}%`}`
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
.groupBy(categorias.id, categorias.name, categorias.icon, orcamentos.amount);
// 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(contas, eq(lancamentos.contaId, contas.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}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`}`
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
.groupBy(categorias.id);
// 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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`}`,
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(categorias.id);
// Cria um mapa do período anterior para busca rápida
const previousMap = new Map<string, number>();
let previousTotal = 0;
// 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;
}
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));
}
// 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;
// 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;
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,
};
});
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);
// Ordena por valor atual (maior para menor)
categories.sort((a, b) => b.currentAmount - a.currentAmount);
return {
categories,
currentTotal,
previousTotal,
};
return {
categories,
currentTotal,
previousTotal,
};
}

View File

@@ -2,8 +2,8 @@
* Common utilities and helpers for dashboard queries
*/
import { safeToNumber } from "@/lib/utils/number";
import { calculatePercentageChange } from "@/lib/utils/math";
import { safeToNumber } from "@/lib/utils/number";
export { safeToNumber, calculatePercentageChange };

View File

@@ -1,179 +1,179 @@
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
import { cartoes, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm";
// Calcula a data de vencimento baseada no período e dia de vencimento do cartão
function calculateDueDate(period: string, dueDay: string | null): Date | null {
if (!dueDay) return null;
if (!dueDay) return null;
try {
const [year, month] = period.split("-");
if (!year || !month) return null;
try {
const [year, month] = period.split("-");
if (!year || !month) return null;
const day = parseInt(dueDay, 10);
if (isNaN(day)) return null;
const day = parseInt(dueDay, 10);
if (Number.isNaN(day)) return null;
// Criar data ao meio-dia para evitar problemas de timezone
return new Date(parseInt(year), parseInt(month) - 1, day, 12, 0, 0);
} catch {
return null;
}
// Criar data ao meio-dia para evitar problemas de timezone
return new Date(parseInt(year, 10), parseInt(month, 10) - 1, day, 12, 0, 0);
} catch {
return null;
}
}
export type InstallmentDetail = {
id: string;
currentInstallment: number;
amount: number;
dueDate: Date | null;
period: string;
isAnticipated: boolean;
purchaseDate: Date;
isSettled: boolean;
id: string;
currentInstallment: number;
amount: number;
dueDate: Date | null;
period: string;
isAnticipated: boolean;
purchaseDate: Date;
isSettled: boolean;
};
export type InstallmentGroup = {
seriesId: string;
name: string;
paymentMethod: string;
cartaoId: string | null;
cartaoName: string | null;
cartaoDueDay: string | null;
cartaoLogo: string | null;
totalInstallments: number;
paidInstallments: number;
pendingInstallments: InstallmentDetail[];
totalPendingAmount: number;
firstPurchaseDate: Date;
seriesId: string;
name: string;
paymentMethod: string;
cartaoId: string | null;
cartaoName: string | null;
cartaoDueDay: string | null;
cartaoLogo: string | null;
totalInstallments: number;
paidInstallments: number;
pendingInstallments: InstallmentDetail[];
totalPendingAmount: number;
firstPurchaseDate: Date;
};
export type InstallmentAnalysisData = {
installmentGroups: InstallmentGroup[];
totalPendingInstallments: number;
installmentGroups: InstallmentGroup[];
totalPendingInstallments: number;
};
export async function fetchInstallmentAnalysis(
userId: string
userId: string,
): Promise<InstallmentAnalysisData> {
// 1. Buscar todos os lançamentos parcelados não antecipados do pagador admin
const installmentRows = await db
.select({
id: lancamentos.id,
seriesId: lancamentos.seriesId,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
currentInstallment: lancamentos.currentInstallment,
installmentCount: lancamentos.installmentCount,
dueDate: lancamentos.dueDate,
period: lancamentos.period,
isAnticipated: lancamentos.isAnticipated,
isSettled: lancamentos.isSettled,
purchaseDate: lancamentos.purchaseDate,
cartaoId: lancamentos.cartaoId,
cartaoName: cartoes.name,
cartaoDueDay: cartoes.dueDay,
cartaoLogo: cartoes.logo,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
isNotNull(lancamentos.seriesId),
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(lancamentos.purchaseDate, lancamentos.currentInstallment);
// 1. Buscar todos os lançamentos parcelados não antecipados do pagador admin
const installmentRows = await db
.select({
id: lancamentos.id,
seriesId: lancamentos.seriesId,
name: lancamentos.name,
amount: lancamentos.amount,
paymentMethod: lancamentos.paymentMethod,
currentInstallment: lancamentos.currentInstallment,
installmentCount: lancamentos.installmentCount,
dueDate: lancamentos.dueDate,
period: lancamentos.period,
isAnticipated: lancamentos.isAnticipated,
isSettled: lancamentos.isSettled,
purchaseDate: lancamentos.purchaseDate,
cartaoId: lancamentos.cartaoId,
cartaoName: cartoes.name,
cartaoDueDay: cartoes.dueDay,
cartaoLogo: cartoes.logo,
})
.from(lancamentos)
.leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
.leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.transactionType, "Despesa"),
eq(lancamentos.condition, "Parcelado"),
eq(lancamentos.isAnticipated, false),
isNotNull(lancamentos.seriesId),
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(lancamentos.purchaseDate, lancamentos.currentInstallment);
// Agrupar por seriesId
const seriesMap = new Map<string, InstallmentGroup>();
// Agrupar por seriesId
const seriesMap = new Map<string, InstallmentGroup>();
for (const row of installmentRows) {
if (!row.seriesId) continue;
for (const row of installmentRows) {
if (!row.seriesId) continue;
const amount = Math.abs(toNumber(row.amount));
const amount = Math.abs(toNumber(row.amount));
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
const calculatedDueDate = row.cartaoDueDay
? calculateDueDate(row.period, row.cartaoDueDay)
: row.dueDate;
// Calcular vencimento correto baseado no período e dia de vencimento do cartão
const calculatedDueDate = row.cartaoDueDay
? calculateDueDate(row.period, row.cartaoDueDay)
: row.dueDate;
const installmentDetail: InstallmentDetail = {
id: row.id,
currentInstallment: row.currentInstallment ?? 1,
amount,
dueDate: calculatedDueDate,
period: row.period,
isAnticipated: row.isAnticipated ?? false,
purchaseDate: row.purchaseDate,
isSettled: row.isSettled ?? false,
};
const installmentDetail: InstallmentDetail = {
id: row.id,
currentInstallment: row.currentInstallment ?? 1,
amount,
dueDate: calculatedDueDate,
period: row.period,
isAnticipated: row.isAnticipated ?? false,
purchaseDate: row.purchaseDate,
isSettled: row.isSettled ?? false,
};
if (seriesMap.has(row.seriesId)) {
const group = seriesMap.get(row.seriesId)!;
group.pendingInstallments.push(installmentDetail);
group.totalPendingAmount += amount;
} else {
seriesMap.set(row.seriesId, {
seriesId: row.seriesId,
name: row.name,
paymentMethod: row.paymentMethod,
cartaoId: row.cartaoId,
cartaoName: row.cartaoName,
cartaoDueDay: row.cartaoDueDay,
cartaoLogo: row.cartaoLogo,
totalInstallments: row.installmentCount ?? 0,
paidInstallments: 0,
pendingInstallments: [installmentDetail],
totalPendingAmount: amount,
firstPurchaseDate: row.purchaseDate,
});
}
}
if (seriesMap.has(row.seriesId)) {
const group = seriesMap.get(row.seriesId)!;
group.pendingInstallments.push(installmentDetail);
group.totalPendingAmount += amount;
} else {
seriesMap.set(row.seriesId, {
seriesId: row.seriesId,
name: row.name,
paymentMethod: row.paymentMethod,
cartaoId: row.cartaoId,
cartaoName: row.cartaoName,
cartaoDueDay: row.cartaoDueDay,
cartaoLogo: row.cartaoLogo,
totalInstallments: row.installmentCount ?? 0,
paidInstallments: 0,
pendingInstallments: [installmentDetail],
totalPendingAmount: amount,
firstPurchaseDate: row.purchaseDate,
});
}
}
// Calcular quantas parcelas já foram pagas para cada grupo
const installmentGroups = Array.from(seriesMap.values())
.map((group) => {
// Contar quantas parcelas estão marcadas como pagas (settled)
const paidCount = group.pendingInstallments.filter(
(i) => i.isSettled
).length;
group.paidInstallments = paidCount;
return group;
})
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
.filter((group) => {
const hasUnpaidInstallments = group.pendingInstallments.some(
(i) => !i.isSettled
);
return hasUnpaidInstallments;
});
// Calcular quantas parcelas já foram pagas para cada grupo
const installmentGroups = Array.from(seriesMap.values())
.map((group) => {
// Contar quantas parcelas estão marcadas como pagas (settled)
const paidCount = group.pendingInstallments.filter(
(i) => i.isSettled,
).length;
group.paidInstallments = paidCount;
return group;
})
// Filtrar apenas séries que têm pelo menos uma parcela em aberto (não paga)
.filter((group) => {
const hasUnpaidInstallments = group.pendingInstallments.some(
(i) => !i.isSettled,
);
return hasUnpaidInstallments;
});
// Calcular totais
const totalPendingInstallments = installmentGroups.reduce(
(sum, group) => sum + group.totalPendingAmount,
0
);
// Calcular totais
const totalPendingInstallments = installmentGroups.reduce(
(sum, group) => sum + group.totalPendingAmount,
0,
);
return {
installmentGroups,
totalPendingInstallments,
};
return {
installmentGroups,
totalPendingInstallments,
};
}

View File

@@ -1,96 +1,96 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
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";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type InstallmentExpense = {
id: string;
name: string;
amount: number;
paymentMethod: string;
currentInstallment: number | null;
installmentCount: number | null;
dueDate: Date | null;
purchaseDate: Date;
period: string;
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[];
expenses: InstallmentExpense[];
};
export async function fetchInstallmentExpenses(
userId: string,
period: string
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 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;
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;
});
// Ordena do menor número de parcelas restantes para o maior
return remainingA - remainingB;
});
return {
expenses,
};
return {
expenses,
};
}

View File

@@ -1,66 +1,68 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
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";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type RecurringExpense = {
id: string;
name: string;
amount: number;
paymentMethod: string;
recurrenceCount: number | null;
id: string;
name: string;
amount: number;
paymentMethod: string;
recurrenceCount: number | null;
};
export type RecurringExpensesData = {
expenses: RecurringExpense[];
expenses: RecurringExpense[];
};
export async function fetchRecurringExpenses(
userId: string,
period: string
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 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,
}));
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,
};
return {
expenses,
};
}

View File

@@ -1,84 +1,84 @@
import { and, asc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
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;
id: string;
name: string;
amount: number;
purchaseDate: Date;
paymentMethod: string;
logo?: string | null;
};
export type TopExpensesData = {
expenses: TopExpense[];
expenses: TopExpense[];
};
export async function fetchTopExpenses(
userId: string,
period: string,
cardOnly: boolean = false
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}%`}`
)
),
];
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"));
}
// 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 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,
})
);
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,
};
return {
expenses,
};
}

View File

@@ -17,66 +17,66 @@ 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),
]);
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,
};
return {
metrics,
accountsSnapshot,
invoicesSnapshot,
boletosSnapshot,
notificationsSnapshot,
paymentStatusData,
incomeExpenseBalanceData,
recentTransactionsData,
paymentConditionsData,
paymentMethodsData,
recurringExpensesData,
installmentExpensesData,
topEstablishmentsData,
topExpensesAll,
topExpensesCardOnly,
purchasesByCategoryData,
incomeByCategoryData,
expensesByCategoryData,
};
}
export type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>;

View File

@@ -1,145 +1,148 @@
import { lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import { db } from "@/lib/db";
import { and, eq, isNull, ne, or, sql } from "drizzle-orm";
import { contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, sql, or, isNull, ne } from "drizzle-orm";
import { db } from "@/lib/db";
export type MonthData = {
month: string;
monthLabel: string;
income: number;
expense: number;
balance: number;
month: string;
monthLabel: string;
income: number;
expense: number;
balance: number;
};
export type IncomeExpenseBalanceData = {
months: MonthData[];
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",
"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);
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;
}
if (Number.isNaN(year) || Number.isNaN(month)) {
const now = new Date();
year = now.getFullYear();
month = now.getMonth() + 1;
}
const periods: string[] = [];
const periods: string[] = [];
for (let i = 5; i >= 0; i--) {
let targetMonth = month - i;
let targetYear = year;
for (let i = 5; i >= 0; i--) {
let targetMonth = month - i;
let targetYear = year;
while (targetMonth <= 0) {
targetMonth += 12;
targetYear -= 1;
}
while (targetMonth <= 0) {
targetMonth += 12;
targetYear -= 1;
}
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
}
periods.push(`${targetYear}-${String(targetMonth).padStart(2, "0")}`);
}
return periods;
return periods;
};
export async function fetchIncomeExpenseBalance(
userId: string,
currentPeriod: string
userId: string,
currentPeriod: string,
): Promise<IncomeExpenseBalanceData> {
const periods = generateLast6Months(currentPeriod);
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>`
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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
);
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`})`,
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
);
// Busca despesas do período
const [expenseRow] = await db
.select({
total: sql<number>`
// 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}%`})`
)
);
})
.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 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;
const [, monthPart] = period.split("-");
const monthLabel = MONTH_LABELS[monthPart ?? "01"] ?? monthPart;
return {
month: period,
monthLabel: monthLabel ?? "",
income,
expense,
balance,
};
})
);
return {
month: period,
monthLabel: monthLabel ?? "",
income,
expense,
balance,
};
}),
);
return {
months: results,
};
return {
months: results,
};
}

View File

@@ -1,279 +1,279 @@
import { and, eq, ilike, isNotNull, sql } from "drizzle-orm";
import { cartoes, faturas, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import {
INVOICE_PAYMENT_STATUS,
INVOICE_STATUS_VALUES,
type InvoicePaymentStatus,
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;
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;
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[];
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;
invoices: DashboardInvoice[];
totalPending: number;
};
const toISODate = (value: Date | string | null | undefined) => {
if (!value) {
return null;
}
if (!value) {
return null;
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (value instanceof Date) {
return value.toISOString().slice(0, 10);
}
if (typeof value === "string") {
return value.slice(0, 10);
}
if (typeof value === "string") {
return value.slice(0, 10);
}
return null;
return null;
};
const isInvoiceStatus = (value: unknown): value is InvoicePaymentStatus =>
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
typeof value === "string" &&
(INVOICE_STATUS_VALUES as string[]).includes(value);
const buildFallbackId = (cardId: string, period: string) =>
`${cardId}:${period}`;
`${cardId}:${period}`;
export async function fetchDashboardInvoices(
userId: string,
period: string
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 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 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>`
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
),
]);
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 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 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 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;
const shouldInclude =
transactionCount > 0 ||
Math.abs(totalAmount) > 0 ||
row.invoiceId !== null;
if (!shouldInclude) {
return 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;
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);
});
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);
const totalPending = invoices.reduce((total, invoice) => {
if (invoice.paymentStatus !== INVOICE_PAYMENT_STATUS.PENDING) {
return total;
}
return total + invoice.totalAmount;
}, 0);
return {
invoices,
totalPending,
};
return {
invoices,
totalPending,
};
}

View File

@@ -1,162 +1,171 @@
import { lancamentos, pagadores, contas } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX, INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants";
import {
and,
asc,
eq,
ilike,
isNull,
lte,
ne,
not,
or,
sum,
} from "drizzle-orm";
import { 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 {
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";
import {
buildPeriodRange,
comparePeriods,
getPreviousPeriod,
} from "@/lib/utils/period";
const RECEITA = "Receita";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
type MetricPair = {
current: number;
previous: number;
current: number;
previous: number;
};
export type DashboardCardMetrics = {
period: string;
previousPeriod: string;
receitas: MetricPair;
despesas: MetricPair;
balanco: MetricPair;
previsto: MetricPair;
period: string;
previousPeriod: string;
receitas: MetricPair;
despesas: MetricPair;
balanco: MetricPair;
previsto: MetricPair;
};
type PeriodTotals = {
receitas: number;
despesas: number;
balanco: number;
receitas: number;
despesas: number;
balanco: number;
};
const createEmptyTotals = (): PeriodTotals => ({
receitas: 0,
despesas: 0,
balanco: 0,
receitas: 0,
despesas: 0,
balanco: 0,
});
const ensurePeriodTotals = (
store: Map<string, PeriodTotals>,
period: string
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;
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
userId: string,
period: string,
): Promise<DashboardCardMetrics> {
const previousPeriod = getPreviousPeriod(period);
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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`
)
)
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false)
)
)
)
.groupBy(lancamentos.period, lancamentos.transactionType)
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
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))
.leftJoin(contas, eq(lancamentos.contaId, contas.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}%`)),
),
// Excluir saldos iniciais se a conta tiver o flag ativo
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.period, lancamentos.transactionType)
.orderBy(asc(lancamentos.period), asc(lancamentos.transactionType));
const periodTotals = new Map<string, PeriodTotals>();
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);
}
}
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);
ensurePeriodTotals(periodTotals, period);
ensurePeriodTotals(periodTotals, previousPeriod);
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const earliestPeriod =
periodTotals.size > 0 ? Array.from(periodTotals.keys()).sort()[0] : period;
const startPeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const startPeriod =
comparePeriods(earliestPeriod, previousPeriod) <= 0
? earliestPeriod
: previousPeriod;
const periodRange = buildPeriodRange(startPeriod, period);
const forecastByPeriod = new Map<string, number>();
let runningForecast = 0;
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);
}
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);
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,
},
};
return {
period,
previousPeriod,
receitas: {
current: currentTotals.receitas,
previous: previousTotals.receitas,
},
despesas: {
current: currentTotals.despesas,
previous: previousTotals.despesas,
},
balanco: {
current: currentTotals.balanco,
previous: previousTotals.balanco,
},
previsto: {
current: forecastByPeriod.get(period) ?? runningForecast,
previous: forecastByPeriod.get(previousPeriod) ?? 0,
},
};
}

View File

@@ -1,26 +1,26 @@
"use server";
import { and, eq, lt, sql } from "drizzle-orm";
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
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;
notifications: DashboardNotification[];
totalCount: number;
};
const PAYMENT_METHOD_BOLETO = "Boleto";
@@ -32,67 +32,72 @@ const PAYMENT_METHOD_BOLETO = "Boleto";
* @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 [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 daysInMonth = hasValidMonth
? new Date(yearNumber, monthNumber, 0).getDate()
: null;
const dueDayNumber = Number(dueDay);
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
const dueDayNumber = Number(dueDay);
const hasValidDueDay = Number.isInteger(dueDayNumber) && dueDayNumber > 0;
const clampedDay =
hasValidMonth && hasValidDueDay && daysInMonth
? Math.min(dueDayNumber, daysInMonth)
: hasValidDueDay
? dueDayNumber
: null;
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 day = clampedDay
? String(clampedDay).padStart(2, "0")
: dueDay.padStart(2, "0");
const normalizedMonth =
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
const normalizedMonth =
hasValidMonth && month.length < 2 ? month.padStart(2, "0") : month;
return `${year}-${normalizedMonth}-${day}`;
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
));
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));
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);
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
return dueNormalized < today;
return dueNormalized < today;
}
/**
@@ -100,19 +105,19 @@ function isOverdue(dueDate: string, today: Date): boolean {
* Exemplo: Se hoje é dia 4 e daysThreshold = 5, retorna true para datas de 4 a 8
*/
function isDueWithinDays(
dueDate: string,
today: Date,
daysThreshold: number
dueDate: string,
today: Date,
daysThreshold: number,
): boolean {
const due = parseUTCDate(dueDate);
const dueNormalized = normalizeDate(due);
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);
// 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;
// Vence se está entre hoje (inclusive) e a data limite (inclusive)
return dueNormalized >= today && dueNormalized <= limitDate;
}
/**
@@ -127,22 +132,22 @@ function isDueWithinDays(
* - "due_soon": vencimento no dia atual ou nos próximos dias
*/
export async function fetchDashboardNotifications(
userId: string,
currentPeriod: string
userId: string,
currentPeriod: string,
): Promise<DashboardNotificationsSnapshot> {
const today = normalizeDate(new Date());
const DAYS_THRESHOLD = 5;
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>`
// 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}
@@ -152,222 +157,223 @@ export async function fetchDashboardNotifications(
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)
)
);
})
.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>`
// 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
);
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")
)
);
// 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[] = [];
const notifications: DashboardNotification[] = [];
// Processar faturas atrasadas (períodos anteriores)
for (const invoice of overdueInvoices) {
if (!invoice.period || !invoice.dueDay) continue;
// 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 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}`;
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
});
}
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;
// 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 amount =
typeof invoice.totalAmount === "number"
? invoice.totalAmount
: Number(invoice.totalAmount) || 0;
const transactionCount =
typeof invoice.transactionCount === "number"
? invoice.transactionCount
: Number(invoice.transactionCount) || 0;
const transactionCount =
typeof invoice.transactionCount === "number"
? invoice.transactionCount
: Number(invoice.transactionCount) || 0;
const paymentStatus = invoice.paymentStatus ?? INVOICE_PAYMENT_STATUS.PENDING;
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;
// 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;
if (!shouldInclude) continue;
// Ignora se já foi paga
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
// Ignora se já foi paga
if (paymentStatus === INVOICE_PAYMENT_STATUS.PAID) continue;
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const dueDate = calculateDueDate(invoice.period, invoice.dueDay);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const invoiceIsOverdue = isOverdue(dueDate, today);
const invoiceIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
if (!invoiceIsOverdue && !invoiceIsDueSoon) continue;
const notificationId = invoice.invoiceId
? `invoice-${invoice.invoiceId}`
: `invoice-${invoice.cardId}-${invoice.period}`;
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,
});
}
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;
// 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;
// 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 boletoIsOverdue = isOverdue(dueDate, today);
const boletoIsDueSoon = isDueWithinDays(dueDate, today, DAYS_THRESHOLD);
const isOldPeriod = boleto.period < currentPeriod;
const isCurrentPeriod = boleto.period === currentPeriod;
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;
// 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
});
}
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;
// 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,
});
}
}
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);
});
// 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,
};
return {
notifications,
totalCount: notifications.length,
};
}

View File

@@ -1,79 +1,79 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
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";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type PaymentConditionSummary = {
condition: string;
amount: number;
percentage: number;
transactions: number;
condition: string;
amount: number;
percentage: number;
transactions: number;
};
export type PaymentConditionsData = {
conditions: PaymentConditionSummary[];
conditions: PaymentConditionSummary[];
};
export async function fetchPaymentConditions(
userId: string,
period: string
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 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);
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,
};
});
return {
condition: row.condition,
amount: totalAmount,
transactions,
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
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);
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,
};
return {
conditions,
};
}

View File

@@ -1,79 +1,79 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
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";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type PaymentMethodSummary = {
paymentMethod: string;
amount: number;
percentage: number;
transactions: number;
paymentMethod: string;
amount: number;
percentage: number;
transactions: number;
};
export type PaymentMethodsData = {
methods: PaymentMethodSummary[];
methods: PaymentMethodSummary[];
};
export async function fetchPaymentMethods(
userId: string,
period: string
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 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);
const summaries = rows.map((row) => {
const amount = Math.abs(toNumber(row.totalAmount));
const transactions = Number(row.transactions ?? 0);
return {
paymentMethod: row.paymentMethod,
amount,
transactions,
};
});
return {
paymentMethod: row.paymentMethod,
amount,
transactions,
};
});
const overallTotal = summaries.reduce((acc, item) => acc + item.amount, 0);
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);
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,
};
return {
methods,
};
}

View File

@@ -1,29 +1,29 @@
import { and, eq, sql } from "drizzle-orm";
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";
import { db } from "@/lib/db";
export type PaymentStatusCategory = {
total: number;
confirmed: number;
pending: number;
total: number;
confirmed: number;
pending: number;
};
export type PaymentStatusData = {
income: PaymentStatusCategory;
expenses: PaymentStatusCategory;
income: PaymentStatusCategory;
expenses: PaymentStatusCategory;
};
export async function fetchPaymentStatus(
userId: string,
period: string
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>`
// 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
@@ -34,7 +34,7 @@ export async function fetchPaymentStatus(
0
)
`,
pending: sql<number>`
pending: sql<number>`
coalesce(
sum(
case
@@ -45,24 +45,24 @@ export async function fetchPaymentStatus(
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}%`})`
)
);
})
.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>`
// 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
@@ -73,7 +73,7 @@ export async function fetchPaymentStatus(
0
)
`,
pending: sql<number>`
pending: sql<number>`
coalesce(
sum(
case
@@ -84,37 +84,37 @@ export async function fetchPaymentStatus(
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}%`})`
)
);
})
.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 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);
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,
},
};
return {
income: {
total: confirmedIncome + pendingIncome,
confirmed: confirmedIncome,
pending: pendingIncome,
},
expenses: {
total: confirmedExpenses + pendingExpenses,
confirmed: confirmedExpenses,
pending: pendingExpenses,
},
};
}

View File

@@ -1,145 +1,145 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
cartoes,
categorias,
contas,
lancamentos,
pagadores,
} from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
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;
id: string;
name: string;
type: string;
};
export type CategoryTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
logo: string | null;
id: string;
name: string;
amount: number;
purchaseDate: Date;
logo: string | null;
};
export type PurchasesByCategoryData = {
categories: CategoryOption[];
transactionsByCategory: Record<string, CategoryTransaction[]>;
categories: CategoryOption[];
transactionsByCategory: Record<string, CategoryTransaction[]>;
};
const shouldIncludeTransaction = (name: string) => {
const normalized = name.trim().toLowerCase();
const normalized = name.trim().toLowerCase();
if (normalized === "saldo inicial") {
return false;
}
if (normalized === "saldo inicial") {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
return true;
return true;
};
export async function fetchPurchasesByCategory(
userId: string,
period: string
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 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>();
const transactionsByCategory: Record<string, CategoryTransaction[]> = {};
const categoriesMap = new Map<string, CategoryOption>();
for (const row of transactionsRows) {
const categoryId = row.categoryId;
for (const row of transactionsRows) {
const categoryId = row.categoryId;
if (!categoryId) {
continue;
}
if (!categoryId) {
continue;
}
if (!shouldIncludeTransaction(row.name)) {
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,
});
}
// 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,
};
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] = [];
}
if (!transactionsByCategory[categoryId]) {
transactionsByCategory[categoryId] = [];
}
const categoryTransactions = transactionsByCategory[categoryId];
if (categoryTransactions && categoryTransactions.length < 10) {
categoryTransactions.push(entry);
}
}
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);
});
// 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,
};
return {
categories,
transactionsByCategory,
};
}

View File

@@ -1,71 +1,74 @@
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 { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
export type RecentTransaction = {
id: string;
name: string;
amount: number;
purchaseDate: Date;
cardLogo: string | null;
accountLogo: string | null;
id: string;
name: string;
amount: number;
purchaseDate: Date;
cardLogo: string | null;
accountLogo: string | null;
};
export type RecentTransactionsData = {
transactions: RecentTransaction[];
transactions: RecentTransaction[];
};
export async function fetchRecentTransactions(
userId: string,
period: string
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 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,
};
});
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,
};
return {
transactions,
};
}

View File

@@ -1,86 +1,86 @@
import { and, eq, isNull, or, sql } from "drizzle-orm";
import { cartoes, contas, lancamentos, pagadores } from "@/db/schema";
import {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
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;
id: string;
name: string;
amount: number;
occurrences: number;
logo: string | null;
};
export type TopEstablishmentsData = {
establishments: TopEstablishment[];
establishments: TopEstablishment[];
};
const shouldIncludeEstablishment = (name: string) => {
const normalized = name.trim().toLowerCase();
const normalized = name.trim().toLowerCase();
if (normalized === "saldo inicial") {
return false;
}
if (normalized === "saldo inicial") {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
if (normalized.includes("fatura")) {
return false;
}
return true;
return true;
};
export async function fetchTopEstablishments(
userId: string,
period: string
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 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,
})
);
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,
};
return {
establishments,
};
}

View File

@@ -1,87 +1,87 @@
"use server";
import { getUser } from "@/lib/auth/server";
import { db, schema } from "@/lib/db";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getUser } from "@/lib/auth/server";
import { db, schema } from "@/lib/db";
export type WidgetPreferences = {
order: string[];
hidden: string[];
order: string[];
hidden: string[];
};
export async function updateWidgetPreferences(
preferences: WidgetPreferences,
preferences: WidgetPreferences,
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getUser();
try {
const user = await getUser();
// Check if preferences exist
const existing = await db
.select({ id: schema.userPreferences.id })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
// Check if preferences exist
const existing = await db
.select({ id: schema.userPreferences.id })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
if (existing.length > 0) {
await db
.update(schema.userPreferences)
.set({
dashboardWidgets: preferences,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, user.id));
} else {
await db.insert(schema.userPreferences).values({
userId: user.id,
dashboardWidgets: preferences,
});
}
if (existing.length > 0) {
await db
.update(schema.userPreferences)
.set({
dashboardWidgets: preferences,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, user.id));
} else {
await db.insert(schema.userPreferences).values({
userId: user.id,
dashboardWidgets: preferences,
});
}
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error updating widget preferences:", error);
return { success: false, error: "Erro ao salvar preferências" };
}
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error updating widget preferences:", error);
return { success: false, error: "Erro ao salvar preferências" };
}
}
export async function resetWidgetPreferences(): Promise<{
success: boolean;
error?: string;
success: boolean;
error?: string;
}> {
try {
const user = await getUser();
try {
const user = await getUser();
await db
.update(schema.userPreferences)
.set({
dashboardWidgets: null,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, user.id));
await db
.update(schema.userPreferences)
.set({
dashboardWidgets: null,
updatedAt: new Date(),
})
.where(eq(schema.userPreferences.userId, user.id));
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error resetting widget preferences:", error);
return { success: false, error: "Erro ao resetar preferências" };
}
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Error resetting widget preferences:", error);
return { success: false, error: "Erro ao resetar preferências" };
}
}
export async function getWidgetPreferences(): Promise<WidgetPreferences | null> {
try {
const user = await getUser();
try {
const user = await getUser();
const result = await db
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
const result = await db
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
.from(schema.userPreferences)
.where(eq(schema.userPreferences.userId, user.id))
.limit(1);
return result[0]?.dashboardWidgets ?? null;
} catch (error) {
console.error("Error getting widget preferences:", error);
return null;
}
return result[0]?.dashboardWidgets ?? null;
} catch (error) {
console.error("Error getting widget preferences:", error);
return null;
}
}

View File

@@ -1,3 +1,22 @@
import {
RiArrowRightLine,
RiArrowUpDoubleLine,
RiBarChartBoxLine,
RiBarcodeLine,
RiBillLine,
RiExchangeLine,
RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine,
RiPieChartLine,
RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { BoletosWidget } from "@/components/dashboard/boletos-widget";
import { ExpensesByCategoryWidgetWithChart } from "@/components/dashboard/expenses-by-category-widget-with-chart";
import { IncomeByCategoryWidgetWithChart } from "@/components/dashboard/income-by-category-widget-with-chart";
@@ -13,201 +32,182 @@ import { RecentTransactionsWidget } from "@/components/dashboard/recent-transact
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 {
RiArrowRightLine,
RiArrowUpDoubleLine,
RiBarChartBoxLine,
RiBarcodeLine,
RiBillLine,
RiExchangeLine,
RiLineChartLine,
RiMoneyDollarCircleLine,
RiNumbersLine,
RiPieChartLine,
RiRefreshLine,
RiSlideshowLine,
RiStore2Line,
RiStore3Line,
RiWallet3Line,
} from "@remixicon/react";
import Link from "next/link";
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;
action?: ReactNode;
id: string;
title: string;
subtitle: string;
icon: ReactNode;
component: (props: { data: DashboardData; period: string }) => ReactNode;
action?: 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} />
),
action: (
<Link
href="/dashboard/analise-parcelas"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Análise
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
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} />
),
action: (
<Link
href="/top-estabelecimentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver mais
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
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 }) => (
<IncomeByCategoryWidgetWithChart
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 }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
{
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} />
),
action: (
<Link
href="/dashboard/analise-parcelas"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Análise
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
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} />
),
action: (
<Link
href="/top-estabelecimentos"
className="text-sm font-medium text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1"
>
Ver mais
<RiArrowRightLine className="size-4" />
</Link>
),
},
{
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 }) => (
<IncomeByCategoryWidgetWithChart
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 }) => (
<ExpensesByCategoryWidgetWithChart
data={data.expensesByCategoryData}
period={period}
/>
),
},
];

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ import type { EligibleInstallment } from "./anticipation-types";
* Calcula o valor total de antecipação baseado nas parcelas selecionadas
*/
export function calculateTotalAnticipationAmount(
installments: EligibleInstallment[]
installments: EligibleInstallment[],
): number {
return installments.reduce((sum, inst) => sum + Number(inst.amount), 0);
return installments.reduce((sum, inst) => sum + Number(inst.amount), 0);
}
/**
@@ -15,16 +15,16 @@ export function calculateTotalAnticipationAmount(
* O período não pode ser anterior ao período da primeira parcela selecionada
*/
export function validateAnticipationPeriod(
period: string,
installments: EligibleInstallment[]
period: string,
installments: EligibleInstallment[],
): boolean {
if (installments.length === 0) return false;
if (installments.length === 0) return false;
const earliestPeriod = installments.reduce((earliest, inst) => {
return inst.period < earliest ? inst.period : earliest;
}, installments[0].period);
const earliestPeriod = installments.reduce((earliest, inst) => {
return inst.period < earliest ? inst.period : earliest;
}, installments[0].period);
return period >= earliestPeriod;
return period >= earliestPeriod;
}
/**
@@ -32,14 +32,14 @@ export function validateAnticipationPeriod(
* Exemplo: "1, 2, 3" ou "5, 6, 7, 8"
*/
export function getAnticipatedInstallmentNumbers(
installments: EligibleInstallment[]
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;
const numbers = installments
.map((inst) => inst.currentInstallment)
.filter((num): num is number => num !== null)
.sort((a, b) => a - b)
.join(", ");
return numbers;
}
/**
@@ -47,34 +47,34 @@ export function getAnticipatedInstallmentNumbers(
* Exemplo: "Parcelas 1-3 de 12" ou "Parcela 5 de 12"
*/
export function formatAnticipatedInstallmentsRange(
installments: EligibleInstallment[]
installments: EligibleInstallment[],
): string {
const numbers = installments
.map((inst) => inst.currentInstallment)
.filter((num): num is number => num !== null)
.sort((a, b) => a - b);
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}`;
}
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;
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;
});
// 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}`;
}
if (isConsecutive) {
return `Parcelas ${first}-${last} de ${total}`;
} else {
return `${numbers.length} parcelas de ${total}`;
}
}
/**
@@ -82,62 +82,62 @@ export function formatAnticipatedInstallmentsRange(
* Só pode cancelar se o lançamento de antecipação não foi pago
*/
export function canCancelAnticipation(lancamento: Lancamento): boolean {
return lancamento.isSettled !== true;
return lancamento.isSettled !== true;
}
/**
* Ordena parcelas por número da parcela atual
*/
export function sortInstallmentsByNumber(
installments: EligibleInstallment[]
installments: EligibleInstallment[],
): EligibleInstallment[] {
return [...installments].sort((a, b) => {
const aNum = a.currentInstallment ?? 0;
const bNum = b.currentInstallment ?? 0;
return aNum - bNum;
});
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
totalInstallments: number,
anticipatedCount: number,
): number {
return Math.max(0, totalInstallments - anticipatedCount);
return Math.max(0, totalInstallments - anticipatedCount);
}
/**
* Valida se as parcelas selecionadas pertencem à mesma série
*/
export function validateInstallmentsSameSeries(
installments: EligibleInstallment[],
seriesId: string
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;
// 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
lancamentoName: string,
installmentCount: number,
): string {
return `Antecipação de ${installmentCount} ${
installmentCount === 1 ? "parcela" : "parcelas"
} - ${lancamentoName}`;
return `Antecipação de ${installmentCount} ${
installmentCount === 1 ? "parcela" : "parcelas"
} - ${lancamentoName}`;
}
/**
* Formata nota automática para antecipação
*/
export function generateAnticipationNote(
installments: EligibleInstallment[]
installments: EligibleInstallment[],
): string {
const range = formatAnticipatedInstallmentsRange(installments);
return `Antecipação: ${range}`;
const range = formatAnticipatedInstallmentsRange(installments);
return `Antecipação: ${range}`;
}

View File

@@ -1,66 +1,66 @@
import type {
Categoria,
InstallmentAnticipation,
Lancamento,
Pagador,
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;
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;
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;
seriesId: string;
installmentIds: string[];
anticipationPeriod: string;
pagadorId?: string;
categoriaId?: string;
note?: string;
};
/**
* Input para cancelar antecipação
*/
export type CancelAnticipationInput = {
anticipationId: string;
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[];
id: string;
totalAmount: string;
installmentCount: number;
anticipationPeriod: string;
anticipationDate: Date;
note: string | null;
isSettled: boolean;
lancamentoId: string;
anticipatedInstallments: string[];
};

View File

@@ -6,34 +6,34 @@
* @returns Data da última parcela
*/
export function calculateLastInstallmentDate(
currentPeriod: string,
currentInstallment: number,
totalInstallments: number
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
// 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();
}
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);
// 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 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;
// 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);
// Simplificando: monthsToAdd = totalInstallments - currentInstallment
currentDate.setMonth(currentDate.getMonth() + monthsToAdd);
return currentDate;
return currentDate;
}
/**
@@ -41,15 +41,15 @@ export function calculateLastInstallmentDate(
* 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 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);
const formatted = formatter.format(date);
// Capitaliza a primeira letra
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
}
/**
@@ -57,14 +57,14 @@ export function formatLastInstallmentDate(date: Date): string {
* 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",
});
const formatter = new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
timeZone: "UTC",
});
return formatter.format(date);
return formatter.format(date);
}
/**
@@ -72,8 +72,8 @@ export function formatPurchaseDate(date: Date): string {
* Exemplo: "1 de 6"
*/
export function formatCurrentInstallment(
current: number,
total: number
current: number,
total: number,
): string {
return `${current} de ${total}`;
return `${current} de ${total}`;
}

View File

@@ -8,29 +8,31 @@ 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;
return value.length > 0
? value[0]?.toUpperCase().concat(value.slice(1))
: value;
}
/**
* Group label for categorias
*/
type CategoriaGroup = {
label: string;
options: SelectOption[];
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);
const lower = value.toLowerCase();
if (lower === "despesa") {
return "Despesas";
}
if (lower === "receita") {
return "Receitas";
}
return capitalize(value);
}
/**
@@ -39,45 +41,45 @@ function normalizeCategoryGroupLabel(value: string): string {
* @returns Array of grouped and sorted categoria options
*/
export function groupAndSortCategorias(
categoriaOptions: SelectOption[]
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;
},
{}
);
// 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)),
];
// 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" })
),
}));
// 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
allOptions: SelectOption[],
primaryPagadorId?: string,
): SelectOption[] {
return allOptions.filter((option) => option.value !== primaryPagadorId);
return allOptions.filter((option) => option.value !== primaryPagadorId);
}

View File

@@ -1,17 +1,21 @@
export const LANCAMENTO_TRANSACTION_TYPES = ["Despesa", "Receita", "Transferência"] as const;
export const LANCAMENTO_TRANSACTION_TYPES = [
"Despesa",
"Receita",
"Transferência",
] as const;
export const LANCAMENTO_CONDITIONS = [
"À vista",
"Parcelado",
"Recorrente",
"À vista",
"Parcelado",
"Recorrente",
] as const;
export const LANCAMENTO_PAYMENT_METHODS = [
"Cartão de crédito",
"Cartão de débito",
"Pix",
"Dinheiro",
"Boleto",
"Pré-Pago | VR/VA",
"Transferência bancária",
"Cartão de crédito",
"Cartão de débito",
"Pix",
"Dinheiro",
"Boleto",
"Pré-Pago | VR/VA",
"Transferência bancária",
] as const;

View File

@@ -3,151 +3,151 @@
*/
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";
import { derivePeriodFromDate } from "@/lib/utils/period";
import {
LANCAMENTO_CONDITIONS,
LANCAMENTO_PAYMENT_METHODS,
LANCAMENTO_TRANSACTION_TYPES,
} from "./constants";
/**
* 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;
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;
defaultName?: string | null;
defaultAmount?: string | null;
defaultTransactionType?: "Despesa" | "Receita";
isImporting?: boolean;
defaultCartaoId?: string | null;
defaultPaymentMethod?: string | null;
defaultPurchaseDate?: string | null;
defaultName?: string | null;
defaultAmount?: string | null;
defaultTransactionType?: "Despesa" | "Receita";
isImporting?: boolean;
};
/**
* Builds initial form state from lancamento data and defaults
*/
export function buildLancamentoInitialState(
lancamento?: LancamentoItem,
defaultPagadorId?: string | null,
preferredPeriod?: string,
overrides?: LancamentoFormOverrides,
lancamento?: LancamentoItem,
defaultPagadorId?: string | null,
preferredPeriod?: string,
overrides?: LancamentoFormOverrides,
): LancamentoFormState {
const purchaseDate = lancamento?.purchaseDate
? lancamento.purchaseDate.slice(0, 10)
: (overrides?.defaultPurchaseDate ?? "");
const purchaseDate = lancamento?.purchaseDate
? lancamento.purchaseDate.slice(0, 10)
: (overrides?.defaultPurchaseDate ?? "");
const paymentMethod =
lancamento?.paymentMethod ??
overrides?.defaultPaymentMethod ??
LANCAMENTO_PAYMENT_METHODS[0];
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 derivedPeriod = derivePeriodFromDate(purchaseDate);
const fallbackPeriod =
preferredPeriod && /^\d{4}-\d{2}$/.test(preferredPeriod)
? preferredPeriod
: derivedPeriod;
// Quando importando, usar valores padrão do usuário logado ao invés dos valores do lançamento original
const isImporting = overrides?.isImporting ?? false;
const fallbackPagadorId = isImporting
? (defaultPagadorId ?? null)
: (lancamento?.pagadorId ?? defaultPagadorId ?? null);
// Quando importando, usar valores padrão do usuário logado ao invés dos valores do lançamento original
const isImporting = overrides?.isImporting ?? false;
const fallbackPagadorId = isImporting
? (defaultPagadorId ?? null)
: (lancamento?.pagadorId ?? defaultPagadorId ?? null);
const boletoPaymentDate =
lancamento?.boletoPaymentDate ??
(paymentMethod === "Boleto" && (lancamento?.isSettled ?? false)
? getTodayDateString()
: "");
const boletoPaymentDate =
lancamento?.boletoPaymentDate ??
(paymentMethod === "Boleto" && (lancamento?.isSettled ?? false)
? getTodayDateString()
: "");
// Calcular o valor correto para importação de parcelados
let amountValue = overrides?.defaultAmount ?? "";
if (!amountValue && typeof lancamento?.amount === "number") {
let baseAmount = Math.abs(lancamento.amount);
// Calcular o valor correto para importação de parcelados
let amountValue = overrides?.defaultAmount ?? "";
if (!amountValue && typeof lancamento?.amount === "number") {
let baseAmount = Math.abs(lancamento.amount);
// Se está importando e é parcelado, usar o valor total (parcela * quantidade)
if (
isImporting &&
lancamento.condition === "Parcelado" &&
lancamento.installmentCount
) {
baseAmount = baseAmount * lancamento.installmentCount;
}
// Se está importando e é parcelado, usar o valor total (parcela * quantidade)
if (
isImporting &&
lancamento.condition === "Parcelado" &&
lancamento.installmentCount
) {
baseAmount = baseAmount * lancamento.installmentCount;
}
amountValue = (Math.round(baseAmount * 100) / 100).toFixed(2);
}
amountValue = (Math.round(baseAmount * 100) / 100).toFixed(2);
}
return {
purchaseDate,
period:
lancamento?.period && /^\d{4}-\d{2}$/.test(lancamento.period)
? lancamento.period
: fallbackPeriod,
name: lancamento?.name ?? overrides?.defaultName ?? "",
transactionType:
lancamento?.transactionType ??
overrides?.defaultTransactionType ??
LANCAMENTO_TRANSACTION_TYPES[0],
amount: amountValue,
condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0],
paymentMethod,
pagadorId: fallbackPagadorId ?? undefined,
secondaryPagadorId: undefined,
isSplit: false,
contaId:
paymentMethod === "Cartão de crédito"
? undefined
: isImporting
? undefined
: (lancamento?.contaId ?? undefined),
cartaoId:
paymentMethod === "Cartão de crédito"
? isImporting
? (overrides?.defaultCartaoId ?? undefined)
: (lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined)
: undefined,
categoriaId: isImporting
? undefined
: (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 ?? true),
};
return {
purchaseDate,
period:
lancamento?.period && /^\d{4}-\d{2}$/.test(lancamento.period)
? lancamento.period
: fallbackPeriod,
name: lancamento?.name ?? overrides?.defaultName ?? "",
transactionType:
lancamento?.transactionType ??
overrides?.defaultTransactionType ??
LANCAMENTO_TRANSACTION_TYPES[0],
amount: amountValue,
condition: lancamento?.condition ?? LANCAMENTO_CONDITIONS[0],
paymentMethod,
pagadorId: fallbackPagadorId ?? undefined,
secondaryPagadorId: undefined,
isSplit: false,
contaId:
paymentMethod === "Cartão de crédito"
? undefined
: isImporting
? undefined
: (lancamento?.contaId ?? undefined),
cartaoId:
paymentMethod === "Cartão de crédito"
? isImporting
? (overrides?.defaultCartaoId ?? undefined)
: (lancamento?.cartaoId ?? overrides?.defaultCartaoId ?? undefined)
: undefined,
categoriaId: isImporting
? undefined
: (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 ?? true),
};
}
/**
@@ -155,79 +155,79 @@ export function buildLancamentoInitialState(
* This function encapsulates the business logic for field interdependencies
*/
export function applyFieldDependencies(
key: keyof LancamentoFormState,
value: LancamentoFormState[keyof LancamentoFormState],
currentState: LancamentoFormState,
_periodDirty: boolean,
key: keyof LancamentoFormState,
value: LancamentoFormState[keyof LancamentoFormState],
currentState: LancamentoFormState,
_periodDirty: boolean,
): Partial<LancamentoFormState> {
const updates: 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);
// }
// }
// 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 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 ?? true;
}
// 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 ?? true;
}
// 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();
}
}
}
// 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 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 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 = "";
}
}
// 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;
return updates;
}

View File

@@ -6,32 +6,34 @@
* Capitalizes the first letter of a string
*/
function capitalize(value: string): string {
return value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value;
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",
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",
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",
month: "long",
year: "numeric",
});
/**
@@ -41,10 +43,10 @@ export const monthFormatter = new Intl.DateTimeFormat("pt-BR", {
* @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);
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return dateFormatter.format(date);
}
/**
@@ -54,11 +56,11 @@ export function formatDate(value?: string | null): string {
* @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));
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));
}
/**
@@ -68,9 +70,9 @@ export function formatPeriod(value?: string | null): string {
* @example formatCondition("vista") => "À vista"
*/
export function formatCondition(value?: string | null): string {
if (!value) return "—";
if (value.toLowerCase() === "vista") return "À vista";
return capitalize(value);
if (!value) return "—";
if (value.toLowerCase() === "vista") return "À vista";
return capitalize(value);
}
/**
@@ -78,12 +80,14 @@ export function formatCondition(value?: string | null): string {
* @param type - Transaction type (Receita/Despesa)
* @returns Badge variant
*/
export function getTransactionBadgeVariant(type?: string | null): "default" | "destructive" | "secondary" {
if (!type) return "secondary";
const normalized = type.toLowerCase();
return normalized === "receita" || normalized === "saldo inicial"
? "default"
: "destructive";
export function getTransactionBadgeVariant(
type?: string | null,
): "default" | "destructive" | "secondary" {
if (!type) return "secondary";
const normalized = type.toLowerCase();
return normalized === "receita" || normalized === "saldo inicial"
? "default"
: "destructive";
}
/**
@@ -93,5 +97,5 @@ export function getTransactionBadgeVariant(type?: string | null): "default" | "d
* @example formatCurrency(1234.56) => "R$ 1.234,56"
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
return currencyFormatter.format(value);
}

View File

@@ -1,21 +1,24 @@
import type { SQL } from "drizzle-orm";
import { and, eq, ilike, isNotNull, or } from "drizzle-orm";
import type { SelectOption } from "@/components/lancamentos/types";
import {
cartoes,
categorias,
contas,
lancamentos,
pagadores,
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,
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 { and, eq, ilike, isNotNull, or } from "drizzle-orm";
import {
PAGADOR_ROLE_ADMIN,
PAGADOR_ROLE_TERCEIRO,
} from "@/lib/pagadores/constants";
type PagadorRow = typeof pagadores.$inferSelect;
type ContaRow = typeof contas.$inferSelect;
@@ -23,509 +26,519 @@ type CartaoRow = typeof cartoes.$inferSelect;
type CategoriaRow = typeof categorias.$inferSelect;
export type ResolvedSearchParams =
| Record<string, string | string[] | undefined>
| undefined;
| 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;
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;
id: string;
label: string;
slug: string;
};
type PagadorSluggedOption = BaseSluggedOption & {
role: string | null;
avatarUrl: string | null;
role: string | null;
avatarUrl: string | null;
};
type CategoriaSluggedOption = BaseSluggedOption & {
type: string | null;
icon: string | null;
type: string | null;
icon: string | null;
};
type ContaSluggedOption = BaseSluggedOption & {
kind: "conta";
logo: string | null;
accountType: string | null;
kind: "conta";
logo: string | null;
accountType: string | null;
};
type CartaoSluggedOption = BaseSluggedOption & {
kind: "cartao";
logo: string | null;
kind: "cartao";
logo: string | null;
};
export type SluggedFilters = {
pagadorFiltersRaw: PagadorSluggedOption[];
categoriaFiltersRaw: CategoriaSluggedOption[];
contaFiltersRaw: ContaSluggedOption[];
cartaoFiltersRaw: CartaoSluggedOption[];
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>;
pagador: Map<string, string>;
categoria: Map<string, string>;
conta: Map<string, string>;
cartao: Map<string, string>;
};
export type FilterOption = {
slug: string;
label: string;
slug: string;
label: string;
};
export type ContaCartaoFilterOption = FilterOption & {
kind: "conta" | "cartao";
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[];
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
params: ResolvedSearchParams,
key: string,
): string | null => {
const value = params?.[key];
if (!value) {
return null;
}
return Array.isArray(value) ? value[0] ?? null : value;
const value = params?.[key];
if (!value) {
return null;
}
return Array.isArray(value) ? (value[0] ?? null) : value;
};
export const extractLancamentoSearchFilters = (
params: ResolvedSearchParams
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"),
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";
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 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}`;
};
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,
accountType?: string | null
value: string,
label: string | null | undefined,
role?: string | null,
group?: string | null,
slug?: string | null,
avatarUrl?: string | null,
logo?: string | null,
icon?: string | null,
accountType?: 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,
accountType: accountType ?? null,
value,
label: normalizeLabel(label),
role: role ?? null,
group: group ?? null,
slug: slug ?? null,
avatarUrl: avatarUrl ?? null,
logo: logo ?? null,
icon: icon ?? null,
accountType: accountType ?? 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),
}),
]
);
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 };
return { pagadorRows, contaRows, cartaoRows, categoriaRows };
};
export const buildSluggedFilters = ({
pagadorRows,
categoriaRows,
contaRows,
cartaoRows,
pagadorRows,
categoriaRows,
contaRows,
cartaoRows,
}: {
pagadorRows: PagadorRow[];
categoriaRows: CategoriaRow[];
contaRows: ContaRow[];
cartaoRows: CartaoRow[];
pagadorRows: PagadorRow[];
categoriaRows: CategoriaRow[];
contaRows: ContaRow[];
cartaoRows: CartaoRow[];
}): SluggedFilters => {
const pagadorSlugger = createSlugGenerator();
const categoriaSlugger = createSlugGenerator();
const contaCartaoSlugger = createSlugGenerator();
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 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 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,
accountType: conta.accountType ?? 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,
accountType: conta.accountType ?? 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,
};
});
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,
};
return {
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
};
};
export const buildSlugMaps = ({
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
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])),
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: string | null,
): value is (typeof LANCAMENTO_TRANSACTION_TYPES)[number] =>
!!value &&
(LANCAMENTO_TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
!!value &&
(LANCAMENTO_TRANSACTION_TYPES as readonly string[]).includes(value ?? "");
const isValidCondition = (
value: string | null
value: string | null,
): value is (typeof LANCAMENTO_CONDITIONS)[number] =>
!!value && (LANCAMENTO_CONDITIONS as readonly string[]).includes(value ?? "");
!!value && (LANCAMENTO_CONDITIONS as readonly string[]).includes(value ?? "");
const isValidPaymentMethod = (
value: string | null
value: string | null,
): value is (typeof LANCAMENTO_PAYMENT_METHODS)[number] =>
!!value &&
(LANCAMENTO_PAYMENT_METHODS as readonly string[]).includes(value ?? "");
!!value &&
(LANCAMENTO_PAYMENT_METHODS as readonly string[]).includes(value ?? "");
const buildSearchPattern = (value: string | null) =>
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
value ? `%${value.trim().replace(/\s+/g, "%")}%` : null;
export const buildLancamentoWhere = ({
userId,
period,
filters,
slugMaps,
cardId,
accountId,
pagadorId,
userId,
period,
filters,
slugMaps,
cardId,
accountId,
pagadorId,
}: {
userId: string;
period: string;
filters: LancamentoSearchFilters;
slugMaps: SlugMaps;
cardId?: string;
accountId?: string;
pagadorId?: string;
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),
];
const where: SQL[] = [
eq(lancamentos.userId, userId),
eq(lancamentos.period, period),
];
if (pagadorId) {
where.push(eq(lancamentos.pagadorId, pagadorId));
}
if (pagadorId) {
where.push(eq(lancamentos.pagadorId, pagadorId));
}
if (cardId) {
where.push(eq(lancamentos.cartaoId, cardId));
}
if (cardId) {
where.push(eq(lancamentos.cartaoId, cardId));
}
if (accountId) {
where.push(eq(lancamentos.contaId, accountId));
}
if (accountId) {
where.push(eq(lancamentos.contaId, accountId));
}
if (isValidTransaction(filters.transactionFilter)) {
where.push(eq(lancamentos.transactionType, filters.transactionFilter));
}
if (isValidTransaction(filters.transactionFilter)) {
where.push(eq(lancamentos.transactionType, filters.transactionFilter));
}
if (isValidCondition(filters.conditionFilter)) {
where.push(eq(lancamentos.condition, filters.conditionFilter));
}
if (isValidCondition(filters.conditionFilter)) {
where.push(eq(lancamentos.condition, filters.conditionFilter));
}
if (isValidPaymentMethod(filters.paymentFilter)) {
where.push(eq(lancamentos.paymentMethod, filters.paymentFilter));
}
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 (!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.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));
}
}
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),
ilike(lancamentos.paymentMethod, searchPattern),
ilike(lancamentos.condition, searchPattern),
and(isNotNull(contas.name), ilike(contas.name, searchPattern)),
and(isNotNull(cartoes.name), ilike(cartoes.name, searchPattern))
)!
);
}
const searchPattern = buildSearchPattern(filters.searchFilter);
if (searchPattern) {
where.push(
or(
ilike(lancamentos.name, searchPattern),
ilike(lancamentos.note, searchPattern),
ilike(lancamentos.paymentMethod, searchPattern),
ilike(lancamentos.condition, searchPattern),
and(isNotNull(contas.name), ilike(contas.name, searchPattern)),
and(isNotNull(cartoes.name), ilike(cartoes.name, searchPattern)),
)!,
);
}
return where;
return where;
};
type LancamentoRowWithRelations = typeof lancamentos.$inferSelect & {
pagador?: PagadorRow | null;
conta?: ContaRow | null;
cartao?: CartaoRow | null;
categoria?: CategoriaRow | null;
pagador?: PagadorRow | null;
conta?: ContaRow | null;
cartao?: CartaoRow | null;
categoria?: CategoriaRow | null;
};
export const mapLancamentosData = (rows: LancamentoRowWithRelations[]) =>
rows.map((item) => ({
id: item.id,
userId: item.userId,
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,
categoriaIcon: item.categoria?.icon ?? 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",
}));
rows.map((item) => ({
id: item.id,
userId: item.userId,
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,
categoriaIcon: item.categoria?.icon ?? 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" })
);
items.sort((a, b) =>
a.label.localeCompare(b.label, "pt-BR", { sensitivity: "base" }),
);
export const buildOptionSets = ({
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
pagadorRows,
limitCartaoId,
limitContaId,
pagadorFiltersRaw,
categoriaFiltersRaw,
contaFiltersRaw,
cartaoFiltersRaw,
pagadorRows,
limitCartaoId,
limitContaId,
}: SluggedFilters & {
pagadorRows: PagadorRow[];
limitCartaoId?: string;
limitContaId?: string;
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 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 pagadorFilterOptions = sortByLabel(
pagadorFiltersRaw.map(({ slug, label, avatarUrl }) => ({
slug,
label,
avatarUrl,
})),
);
const defaultPagadorId =
pagadorRows.find((pagador) => pagador.role === PAGADOR_ROLE_ADMIN)?.id ??
null;
const defaultPagadorId =
pagadorRows.find((pagador) => pagador.role === PAGADOR_ROLE_ADMIN)?.id ??
null;
const splitPagadorOptions = pagadorOptions.filter(
(option) => option.role === PAGADOR_ROLE_TERCEIRO
);
const splitPagadorOptions = pagadorOptions.filter(
(option) => option.role === PAGADOR_ROLE_TERCEIRO,
);
const contaOptionsSource = limitContaId
? contaFiltersRaw.filter((conta) => conta.id === limitContaId)
: contaFiltersRaw;
const contaOptionsSource = limitContaId
? contaFiltersRaw.filter((conta) => conta.id === limitContaId)
: contaFiltersRaw;
const contaOptions = sortByLabel(
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
toOption(id, label, undefined, undefined, slug, undefined, logo, undefined, accountType)
)
);
const contaOptions = sortByLabel(
contaOptionsSource.map(({ id, label, slug, logo, accountType }) =>
toOption(
id,
label,
undefined,
undefined,
slug,
undefined,
logo,
undefined,
accountType,
),
),
);
const cartaoOptionsSource = limitCartaoId
? cartaoFiltersRaw.filter((cartao) => cartao.id === limitCartaoId)
: cartaoFiltersRaw;
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 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 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 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 }))
);
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,
};
return {
pagadorOptions,
splitPagadorOptions,
defaultPagadorId,
contaOptions,
cartaoOptions,
categoriaOptions,
pagadorFilterOptions,
categoriaFilterOptions,
contaCartaoFilterOptions,
};
};

View File

@@ -11,7 +11,7 @@
* @returns Filename only
*/
export const normalizeLogo = (logo?: string | null) =>
logo?.split("/").filter(Boolean).pop() ?? "";
logo?.split("/").filter(Boolean).pop() ?? "";
/**
* Derives a display name from a logo filename
@@ -21,20 +21,20 @@ export const normalizeLogo = (logo?: string | null) =>
* deriveNameFromLogo("my-company-logo.png") // "My Company Logo"
*/
export const deriveNameFromLogo = (logo?: string | null) => {
if (!logo) {
return "";
}
if (!logo) {
return "";
}
const fileName = normalizeLogo(logo);
const fileName = normalizeLogo(logo);
if (!fileName) {
return "";
}
if (!fileName) {
return "";
}
const withoutExtension = fileName.replace(/\.[^/.]+$/, "");
return withoutExtension
.split(/[-_.\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
const withoutExtension = fileName.replace(/\.[^/.]+$/, "");
return withoutExtension
.split(/[-_.\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(" ");
};

View File

@@ -16,15 +16,15 @@ const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
* @returns Array of logo filenames sorted alphabetically
*/
export async function loadLogoOptions() {
try {
const files = await readdir(LOGOS_DIRECTORY, { withFileTypes: true });
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 [];
}
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 [];
}
}

View File

@@ -1,114 +1,95 @@
import {
pagadores,
pagadorShares,
user as usersTable,
} from "@/db/schema";
import { db } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { pagadores, pagadorShares, user as usersTable } from "@/db/schema";
import { db } from "@/lib/db";
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
canEdit: boolean;
sharedByName: string | null;
sharedByEmail: string | null;
shareId: string | null;
};
export async function fetchPagadoresWithAccess(
userId: string
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 [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 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,
}));
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];
return [...ownedMapped, ...sharedMapped];
}
export async function getPagadorAccess(
userId: string,
pagadorId: string
) {
const pagador = await db.query.pagadores.findFirst({
where: and(eq(pagadores.id, pagadorId)),
});
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) {
return null;
}
if (pagador.userId === userId) {
return {
pagador,
canEdit: true,
share: null as typeof pagadorShares.$inferSelect | 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)
),
});
const share = await db.query.pagadorShares.findFirst({
where: and(
eq(pagadorShares.pagadorId, pagadorId),
eq(pagadorShares.sharedWithUserId, userId),
),
});
if (!share) {
return null;
}
if (!share) {
return null;
}
return { pagador, canEdit: false, share };
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)),
});
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);
return Boolean(pagadorRow);
}
export async function userHasPagadorAccess(
userId: string,
pagadorId: string
) {
const access = await getPagadorAccess(userId, pagadorId);
return Boolean(access);
export async function userHasPagadorAccess(userId: string, pagadorId: string) {
const access = await getPagadorAccess(userId, pagadorId);
return Boolean(access);
}

View File

@@ -4,57 +4,57 @@
* Moved from /lib/pagador-defaults.ts to /lib/pagadores/defaults.ts
*/
import { eq } from "drizzle-orm";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_ROLE_ADMIN,
PAGADOR_STATUS_OPTIONS,
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;
id?: string;
name?: string | null;
email?: string | null;
image?: string | null;
}
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
const userId = user.id;
const userId = user.id;
if (!userId) {
return;
}
if (!userId) {
return;
}
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
const hasAnyPagador = await db.query.pagadores.findFirst({
columns: { id: true, role: true },
where: eq(pagadores.userId, userId),
});
if (hasAnyPagador) {
return;
}
if (hasAnyPagador) {
return;
}
const name =
(user.name && user.name.trim().length > 0
? user.name.trim()
: normalizeNameFromEmail(user.email)) || "Pagador principal";
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;
// 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,
});
await db.insert(pagadores).values({
name,
email: user.email ?? null,
status: DEFAULT_STATUS,
role: PAGADOR_ROLE_ADMIN,
avatarUrl,
note: null,
isAutoSend: false,
userId,
});
}

View File

@@ -2,17 +2,17 @@ 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,
and,
eq,
gte,
ilike,
isNull,
lte,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { sql } from "drizzle-orm";
const RECEITA = "Receita";
const DESPESA = "Despesa";
@@ -20,306 +20,306 @@ 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>;
totalExpenses: number;
totalIncomes: number;
paymentSplits: Record<"card" | "boleto" | "instant", number>;
};
export type PagadorHistoryPoint = {
period: string;
label: string;
receitas: number;
despesas: number;
period: string;
label: string;
receitas: number;
despesas: number;
};
export type PagadorCardUsageItem = {
id: string;
name: string;
logo: string | null;
amount: number;
id: string;
name: string;
logo: string | null;
amount: number;
};
export type PagadorBoletoStats = {
totalAmount: number;
paidAmount: number;
pendingAmount: number;
paidCount: number;
pendingCount: number;
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;
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")}`;
`${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 [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;
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;
}
}
for (let i = 0; i < months; i += 1) {
items.unshift(formatPeriod(currentYear, currentMonth));
currentMonth -= 1;
if (currentMonth < 1) {
currentMonth = 12;
currentYear -= 1;
}
}
return items;
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;
}
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}%`))
);
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
);
type BaseFilters = {
userId: string;
pagadorId: string;
period: string;
userId: string;
pagadorId: string;
period: string;
};
export async function fetchPagadorMonthlyBreakdown({
userId,
pagadorId,
period,
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 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;
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;
}
}
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,
};
return {
totalExpenses,
totalIncomes,
paymentSplits,
};
}
export async function fetchPagadorHistory({
userId,
pagadorId,
period,
months = 6,
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 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 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 }
>();
const totalsByPeriod = new Map<
string,
{ receitas: number; despesas: number }
>();
for (const key of window) {
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
}
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;
}
}
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,
}));
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,
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);
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);
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,
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);
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;
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;
}
}
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,
};
return {
totalAmount: paidAmount + pendingAmount,
paidAmount,
pendingAmount,
paidCount,
pendingCount,
};
}

View File

@@ -1,85 +1,85 @@
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
import { inArray } from "drizzle-orm";
import { Resend } from "resend";
import { pagadores } from "@/db/schema";
import { db } from "@/lib/db";
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;
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[]>;
userLabel: string;
action: ActionType;
entriesByPagador: Map<string, NotificationEntry[]>;
};
const formatCurrency = (value: number) =>
value.toLocaleString("pt-BR", {
style: "currency",
currency: "BRL",
maximumFractionDigits: 2,
});
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",
});
if (!value) return "—";
return value.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const buildHtmlBody = ({
userLabel,
action,
entries,
userLabel,
action,
entries,
}: {
userLabel: string;
action: ActionType;
entries: NotificationEntry[];
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 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>
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>
entry.purchaseDate,
)}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.name ?? "Sem descrição"
}</td>
entry.name ?? "Sem descrição"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.paymentMethod ?? "—"
}</td>
entry.paymentMethod ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
entry.condition ?? "—"
}</td>
entry.condition ?? "—"
}</td>
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
</tr>`;
})
.join("");
})
.join("");
return `
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>
@@ -107,131 +107,131 @@ const buildHtmlBody = ({
};
export async function sendPagadorAutoEmails({
userLabel,
action,
entriesByPagador,
userLabel,
action,
entriesByPagador,
}: PagadorNotificationRequest) {
"use server";
"use server";
if (entriesByPagador.size === 0) {
return;
}
if (entriesByPagador.size === 0) {
return;
}
const resendApiKey = process.env.RESEND_API_KEY;
const resendFrom =
process.env.RESEND_FROM_EMAIL ?? "Opensheets <onboarding@resend.dev>";
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;
}
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 pagadorIds = Array.from(entriesByPagador.keys());
if (pagadorIds.length === 0) {
return;
}
const pagadorRows = await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
const pagadorRows = await db.query.pagadores.findMany({
where: inArray(pagadores.id, pagadorIds),
});
if (pagadorRows.length === 0) {
return;
}
if (pagadorRows.length === 0) {
return;
}
const resend = new Resend(resendApiKey);
const subjectPrefix =
action === "created" ? "Novo lançamento" : "Lançamento removido";
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 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 entries = entriesByPagador.get(pagador.id);
if (!entries || entries.length === 0) {
return;
}
const html = buildHtmlBody({
userLabel,
action,
entries,
});
const html = buildHtmlBody({
userLabel,
action,
entries,
});
await resend.emails.send({
from: resendFrom,
to: pagador.email,
subject: `${subjectPrefix} - ${pagador.name}`,
html,
});
})
);
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
);
}
});
// 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;
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[]
records: RawNotificationRecord[],
): Map<string, NotificationEntry[]> => {
const map = new Map<string, NotificationEntry[]>();
const map = new Map<string, NotificationEntry[]>();
records.forEach((record) => {
if (!record.pagadorId) {
return;
}
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 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 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);
});
const list = map.get(record.pagadorId) ?? [];
list.push(entry);
map.set(record.pagadorId, list);
});
return map;
return map;
};

View File

@@ -11,11 +11,11 @@ import { DEFAULT_PAGADOR_AVATAR } from "./constants";
* Remove qualquer caminho anterior e retorna null se não houver avatar.
*/
export const normalizeAvatarPath = (
avatar: string | null | undefined
avatar: string | null | undefined,
): string | null => {
if (!avatar) return null;
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
if (!avatar) return null;
const file = avatar.split("/").filter(Boolean).pop();
return file ?? avatar;
};
/**
@@ -23,43 +23,43 @@ export const normalizeAvatarPath = (
* 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}`;
}
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 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}`;
// 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
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(" ");
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(" ");
};

View File

@@ -1,421 +1,421 @@
import { and, eq, gte, ilike, inArray, lte, not, sum } from "drizzle-orm";
import {
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
cartoes,
categorias,
faturas,
lancamentos,
pagadores,
} from "@/db/schema";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { safeToNumber } from "@/lib/utils/number";
import { getPreviousPeriod } from "@/lib/utils/period";
import { and, eq, gte, ilike, inArray, lte, not, sum } from "drizzle-orm";
const DESPESA = "Despesa";
export type CardSummary = {
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: number;
currentUsage: number;
usagePercent: number;
previousUsage: number;
changePercent: number;
trend: "up" | "down" | "stable";
status: string;
id: string;
name: string;
brand: string | null;
logo: string | null;
limit: number;
currentUsage: number;
usagePercent: number;
previousUsage: number;
changePercent: number;
trend: "up" | "down" | "stable";
status: string;
};
export type CardDetailData = {
card: CardSummary;
monthlyUsage: {
period: string;
periodLabel: string;
amount: number;
}[];
categoryBreakdown: {
id: string;
name: string;
icon: string | null;
amount: number;
percent: number;
}[];
topExpenses: {
id: string;
name: string;
amount: number;
date: string;
category: string | null;
}[];
invoiceStatus: {
period: string;
status: string | null;
amount: number;
}[];
card: CardSummary;
monthlyUsage: {
period: string;
periodLabel: string;
amount: number;
}[];
categoryBreakdown: {
id: string;
name: string;
icon: string | null;
amount: number;
percent: number;
}[];
topExpenses: {
id: string;
name: string;
amount: number;
date: string;
category: string | null;
}[];
invoiceStatus: {
period: string;
status: string | null;
amount: number;
}[];
};
export type CartoesReportData = {
cards: CardSummary[];
totalLimit: number;
totalUsage: number;
totalUsagePercent: number;
selectedCard: CardDetailData | null;
cards: CardSummary[];
totalLimit: number;
totalUsage: number;
totalUsagePercent: number;
selectedCard: CardDetailData | null;
};
export async function fetchCartoesReportData(
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null,
userId: string,
currentPeriod: string,
selectedCartaoId?: string | null,
): Promise<CartoesReportData> {
const previousPeriod = getPreviousPeriod(currentPeriod);
const previousPeriod = getPreviousPeriod(currentPeriod);
// Fetch all active cards (not inactive)
const allCards = await db
.select({
id: cartoes.id,
name: cartoes.name,
brand: cartoes.brand,
logo: cartoes.logo,
limit: cartoes.limit,
status: cartoes.status,
})
.from(cartoes)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
// Fetch all active cards (not inactive)
const allCards = await db
.select({
id: cartoes.id,
name: cartoes.name,
brand: cartoes.brand,
logo: cartoes.logo,
limit: cartoes.limit,
status: cartoes.status,
})
.from(cartoes)
.where(
and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))),
);
if (allCards.length === 0) {
return {
cards: [],
totalLimit: 0,
totalUsage: 0,
totalUsagePercent: 0,
selectedCard: null,
};
}
if (allCards.length === 0) {
return {
cards: [],
totalLimit: 0,
totalUsage: 0,
totalUsagePercent: 0,
selectedCard: null,
};
}
const cardIds = allCards.map((c) => c.id);
const cardIds = allCards.map((c) => c.id);
// Fetch current period usage by card
const currentUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch current period usage by card
const currentUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch previous period usage by card
const previousUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
// Fetch previous period usage by card
const previousUsageData = await db
.select({
cartaoId: lancamentos.cartaoId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.period, previousPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
inArray(lancamentos.cartaoId, cardIds),
),
)
.groupBy(lancamentos.cartaoId);
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
if (row.cartaoId) {
currentUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
const currentUsageMap = new Map<string, number>();
for (const row of currentUsageData) {
if (row.cartaoId) {
currentUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
const previousUsageMap = new Map<string, number>();
for (const row of previousUsageData) {
if (row.cartaoId) {
previousUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
const previousUsageMap = new Map<string, number>();
for (const row of previousUsageData) {
if (row.cartaoId) {
previousUsageMap.set(
row.cartaoId,
Math.abs(safeToNumber(row.totalAmount)),
);
}
}
// Build card summaries
const cards: CardSummary[] = allCards.map((card) => {
const limit = safeToNumber(card.limit);
const currentUsage = currentUsageMap.get(card.id) || 0;
const previousUsage = previousUsageMap.get(card.id) || 0;
const usagePercent = limit > 0 ? (currentUsage / limit) * 100 : 0;
// Build card summaries
const cards: CardSummary[] = allCards.map((card) => {
const limit = safeToNumber(card.limit);
const currentUsage = currentUsageMap.get(card.id) || 0;
const previousUsage = previousUsageMap.get(card.id) || 0;
const usagePercent = limit > 0 ? (currentUsage / limit) * 100 : 0;
let changePercent = 0;
let trend: "up" | "down" | "stable" = "stable";
if (previousUsage > 0) {
changePercent = ((currentUsage - previousUsage) / previousUsage) * 100;
if (changePercent > 5) trend = "up";
else if (changePercent < -5) trend = "down";
} else if (currentUsage > 0) {
changePercent = 100;
trend = "up";
}
let changePercent = 0;
let trend: "up" | "down" | "stable" = "stable";
if (previousUsage > 0) {
changePercent = ((currentUsage - previousUsage) / previousUsage) * 100;
if (changePercent > 5) trend = "up";
else if (changePercent < -5) trend = "down";
} else if (currentUsage > 0) {
changePercent = 100;
trend = "up";
}
return {
id: card.id,
name: card.name,
brand: card.brand,
logo: card.logo,
limit,
currentUsage,
usagePercent,
previousUsage,
changePercent,
trend,
status: card.status,
};
});
return {
id: card.id,
name: card.name,
brand: card.brand,
logo: card.logo,
limit,
currentUsage,
usagePercent,
previousUsage,
changePercent,
trend,
status: card.status,
};
});
// Sort cards by usage (descending)
cards.sort((a, b) => b.currentUsage - a.currentUsage);
// Sort cards by usage (descending)
cards.sort((a, b) => b.currentUsage - a.currentUsage);
// Calculate totals
const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cards.reduce((acc, c) => acc + c.currentUsage, 0);
const totalUsagePercent =
totalLimit > 0 ? (totalUsage / totalLimit) * 100 : 0;
// Calculate totals
const totalLimit = cards.reduce((acc, c) => acc + c.limit, 0);
const totalUsage = cards.reduce((acc, c) => acc + c.currentUsage, 0);
const totalUsagePercent =
totalLimit > 0 ? (totalUsage / totalLimit) * 100 : 0;
// Fetch selected card details if provided
let selectedCard: CardDetailData | null = null;
const targetCardId =
selectedCartaoId || (cards.length > 0 ? cards[0].id : null);
// Fetch selected card details if provided
let selectedCard: CardDetailData | null = null;
const targetCardId =
selectedCartaoId || (cards.length > 0 ? cards[0].id : null);
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
if (cardSummary) {
selectedCard = await fetchCardDetail(
userId,
targetCardId,
cardSummary,
currentPeriod,
);
}
}
if (targetCardId) {
const cardSummary = cards.find((c) => c.id === targetCardId);
if (cardSummary) {
selectedCard = await fetchCardDetail(
userId,
targetCardId,
cardSummary,
currentPeriod,
);
}
}
return {
cards,
totalLimit,
totalUsage,
totalUsagePercent,
selectedCard,
};
return {
cards,
totalLimit,
totalUsage,
totalUsagePercent,
selectedCard,
};
}
async function fetchCardDetail(
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
userId: string,
cardId: string,
cardSummary: CardSummary,
currentPeriod: string,
): Promise<CardDetailData> {
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
// Build period range for last 12 months
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < 12; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
const startPeriod = periods[0];
const startPeriod = periods[0];
const monthLabels = [
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
];
const monthLabels = [
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
];
// Fetch monthly usage
const monthlyData = await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
// Fetch monthly usage
const monthlyData = await db
.select({
period: lancamentos.period,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.period)
.orderBy(lancamentos.period);
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
const [year, month] = period.split("-");
return {
period,
periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`,
amount: Math.abs(safeToNumber(data?.totalAmount)),
};
});
const monthlyUsage = periods.map((period) => {
const data = monthlyData.find((d) => d.period === period);
const [year, month] = period.split("-");
return {
period,
periodLabel: `${monthLabels[parseInt(month, 10) - 1]}/${year.slice(2)}`,
amount: Math.abs(safeToNumber(data?.totalAmount)),
};
});
// Fetch category breakdown for current period
const categoryData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.categoriaId);
// Fetch category breakdown for current period
const categoryData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.categoriaId);
// Fetch category names
const categoryIds = categoryData
.map((c) => c.categoriaId)
.filter((id): id is string => id !== null);
// Fetch category names
const categoryIds = categoryData
.map((c) => c.categoriaId)
.filter((id): id is string => id !== null);
const categoryNames =
categoryIds.length > 0
? await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))
: [];
const categoryNames =
categoryIds.length > 0
? await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(inArray(categorias.id, categoryIds))
: [];
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
const categoryNameMap = new Map(categoryNames.map((c) => [c.id, c]));
const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0,
);
const totalCategoryAmount = categoryData.reduce(
(acc, c) => acc + Math.abs(safeToNumber(c.totalAmount)),
0,
);
const categoryBreakdown = categoryData
.map((cat) => {
const amount = Math.abs(safeToNumber(cat.totalAmount));
const catInfo = cat.categoriaId
? categoryNameMap.get(cat.categoriaId)
: null;
return {
id: cat.categoriaId || "sem-categoria",
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
};
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
const categoryBreakdown = categoryData
.map((cat) => {
const amount = Math.abs(safeToNumber(cat.totalAmount));
const catInfo = cat.categoriaId
? categoryNameMap.get(cat.categoriaId)
: null;
return {
id: cat.categoriaId || "sem-categoria",
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
amount,
percent:
totalCategoryAmount > 0 ? (amount / totalCategoryAmount) * 100 : 0,
};
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
// Fetch top expenses for current period
const topExpensesData = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
categoriaId: lancamentos.categoriaId,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.orderBy(lancamentos.amount)
.limit(10);
// Fetch top expenses for current period
const topExpensesData = await db
.select({
id: lancamentos.id,
name: lancamentos.name,
amount: lancamentos.amount,
purchaseDate: lancamentos.purchaseDate,
categoriaId: lancamentos.categoriaId,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
eq(lancamentos.cartaoId, cardId),
eq(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.orderBy(lancamentos.amount)
.limit(10);
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
? categoryNameMap.get(expense.categoriaId)
: null;
return {
id: expense.id,
name: expense.name,
amount: Math.abs(safeToNumber(expense.amount)),
date: expense.purchaseDate
? new Date(expense.purchaseDate).toLocaleDateString("pt-BR")
: "",
category: catInfo?.name || null,
};
});
const topExpenses = topExpensesData.map((expense) => {
const catInfo = expense.categoriaId
? categoryNameMap.get(expense.categoriaId)
: null;
return {
id: expense.id,
name: expense.name,
amount: Math.abs(safeToNumber(expense.amount)),
date: expense.purchaseDate
? new Date(expense.purchaseDate).toLocaleDateString("pt-BR")
: "",
category: catInfo?.name || null,
};
});
// Fetch invoice status for last 6 months
const invoiceData = await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
})
.from(faturas)
.where(
and(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);
// Fetch invoice status for last 6 months
const invoiceData = await db
.select({
period: faturas.period,
status: faturas.paymentStatus,
})
.from(faturas)
.where(
and(
eq(faturas.userId, userId),
eq(faturas.cartaoId, cardId),
gte(faturas.period, startPeriod),
lte(faturas.period, currentPeriod),
),
)
.orderBy(faturas.period);
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);
const usage = monthlyUsage.find((m) => m.period === period);
return {
period,
status: invoice?.status || null,
amount: usage?.amount || 0,
};
});
const invoiceStatus = periods.map((period) => {
const invoice = invoiceData.find((i) => i.period === period);
const usage = monthlyUsage.find((m) => m.period === period);
return {
period,
status: invoice?.status || null,
amount: usage?.amount || 0,
};
});
return {
card: cardSummary,
monthlyUsage,
categoryBreakdown,
topExpenses,
invoiceStatus,
};
return {
card: cardSummary,
monthlyUsage,
categoryBreakdown,
topExpenses,
invoiceStatus,
};
}

View File

@@ -2,179 +2,176 @@
* Data fetching function for Category Chart (based on selected filters)
*/
import { categorias, 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 { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { generatePeriodRange } from "./utils";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { generatePeriodRange } from "./utils";
export type CategoryChartData = {
months: string[]; // Short month labels (e.g., "JAN", "FEV")
categories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
months: string[]; // Short month labels (e.g., "JAN", "FEV")
categories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
chartData: Array<{
month: string;
[categoryName: string]: number | string;
}>;
allCategories: Array<{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
}>;
};
export async function fetchCategoryChartData(
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[]
userId: string,
startPeriod: string,
endPeriod: string,
categoryIds?: string[],
): Promise<CategoryChartData> {
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
total: sql<number>`coalesce(sum(abs(${lancamentos.amount})), 0)`,
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
.where(and(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Fetch all categories for the user (for category selection)
const allCategoriesRows = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
type: categorias.type,
})
.from(categorias)
.where(eq(categorias.userId, userId))
.orderBy(categorias.type, categorias.name);
// Map all categories
const allCategories = allCategoriesRows.map((cat: {
id: string;
name: string;
icon: string | null;
type: string;
}) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}));
// Map all categories
const allCategories = allCategoriesRows.map(
(cat: { id: string; name: string; icon: string | null; type: string }) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type as "despesa" | "receita",
}),
);
// Process results into chart format
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
// Process results into chart format
const categoryMap = new Map<
string,
{
id: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
dataByPeriod: Map<string, number>;
}
>();
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
dataByPeriod: new Map(),
});
}
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
dataByPeriod: new Map(),
});
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
}
const categoryItem = categoryMap.get(categoryId)!;
categoryItem.dataByPeriod.set(period, amount);
}
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
// Build chart data
const chartData = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
const monthLabel = format(date, "MMM", { locale: ptBR }).toUpperCase();
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
const dataPoint: { month: string; [key: string]: number | string } = {
month: monthLabel,
};
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
}
// Add data for each category
for (const category of categoryMap.values()) {
const value = category.dataByPeriod.get(period) ?? 0;
dataPoint[category.name] = value;
}
return dataPoint;
});
return dataPoint;
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Generate month labels
const months = periods.map((period) => {
const [year, month] = period.split("-");
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, 1);
return format(date, "MMM", { locale: ptBR }).toUpperCase();
});
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type,
}));
// Build categories array
const categories = Array.from(categoryMap.values()).map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
type: cat.type,
}));
return {
months,
categories,
chartData,
allCategories,
};
return {
months,
categories,
chartData,
allCategories,
};
}

View File

@@ -2,17 +2,17 @@
* Data fetching function for Category Report
*/
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { categorias, lancamentos, pagadores } from "@/db/schema";
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants";
import { toNumber } from "@/lib/dashboard/common";
import { db } from "@/lib/db";
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
import { toNumber } from "@/lib/dashboard/common";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import type {
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
CategoryReportData,
CategoryReportFilters,
CategoryReportItem,
MonthlyData,
} from "./types";
import { calculatePercentageChange, generatePeriodRange } from "./utils";
@@ -24,175 +24,173 @@ import { calculatePercentageChange, generatePeriodRange } from "./utils";
* @returns Complete category report data
*/
export async function fetchCategoryReport(
userId: string,
filters: CategoryReportFilters
userId: string,
filters: CategoryReportFilters,
): Promise<CategoryReportData> {
const { startPeriod, endPeriod, categoryIds } = filters;
const { startPeriod, endPeriod, categoryIds } = filters;
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Generate all periods in the range
const periods = generatePeriodRange(startPeriod, endPeriod);
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(
eq(categorias.type, "despesa"),
eq(categorias.type, "receita")
),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`
),
];
// Build WHERE conditions
const whereConditions = [
eq(lancamentos.userId, userId),
inArray(lancamentos.period, periods),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
or(eq(categorias.type, "despesa"), eq(categorias.type, "receita")),
or(
isNull(lancamentos.note),
sql`${lancamentos.note} NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`,
),
];
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Add optional category filter
if (categoryIds && categoryIds.length > 0) {
whereConditions.push(inArray(categorias.id, categoryIds));
}
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
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(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period
);
// Query to get aggregated data by category and period
const rows = await db
.select({
categoryId: categorias.id,
categoryName: categorias.name,
categoryIcon: categorias.icon,
categoryType: categorias.type,
period: lancamentos.period,
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(...whereConditions))
.groupBy(
categorias.id,
categorias.name,
categorias.icon,
categorias.type,
lancamentos.period,
);
// Process results into CategoryReportData structure
const categoryMap = new Map<string, CategoryReportItem>();
const periodTotalsMap = new Map<string, number>();
// Process results into CategoryReportData structure
const categoryMap = new Map<string, CategoryReportItem>();
const periodTotalsMap = new Map<string, number>();
// Initialize period totals
for (const period of periods) {
periodTotalsMap.set(period, 0);
}
// Initialize period totals
for (const period of periods) {
periodTotalsMap.set(period, 0);
}
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } = row;
// Process each row
for (const row of rows) {
const amount = Math.abs(toNumber(row.total));
const { categoryId, categoryName, categoryIcon, categoryType, period } =
row;
// Get or create category item
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
monthlyData: new Map<string, MonthlyData>(),
total: 0,
});
}
// Get or create category item
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
categoryId,
name: categoryName,
icon: categoryIcon,
type: categoryType as "despesa" | "receita",
monthlyData: new Map<string, MonthlyData>(),
total: 0,
});
}
const categoryItem = categoryMap.get(categoryId)!;
const categoryItem = categoryMap.get(categoryId)!;
// Add monthly data (will calculate percentage later)
categoryItem.monthlyData.set(period, {
period,
amount,
previousAmount: 0, // Will be filled in next step
percentageChange: null, // Will be calculated in next step
});
// Add monthly data (will calculate percentage later)
categoryItem.monthlyData.set(period, {
period,
amount,
previousAmount: 0, // Will be filled in next step
percentageChange: null, // Will be calculated in next step
});
// Update category total
categoryItem.total += amount;
// Update category total
categoryItem.total += amount;
// Update period total
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
periodTotalsMap.set(period, currentPeriodTotal + amount);
}
// Update period total
const currentPeriodTotal = periodTotalsMap.get(period) ?? 0;
periodTotalsMap.set(period, currentPeriodTotal + amount);
}
// Calculate percentage changes (compare with previous period)
for (const categoryItem of categoryMap.values()) {
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
// Calculate percentage changes (compare with previous period)
for (const categoryItem of categoryMap.values()) {
const sortedPeriods = Array.from(categoryItem.monthlyData.keys()).sort();
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period)!;
for (let i = 0; i < sortedPeriods.length; i++) {
const period = sortedPeriods[i];
const monthlyData = categoryItem.monthlyData.get(period)!;
if (i > 0) {
// Get previous period data
const prevPeriod = sortedPeriods[i - 1];
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
const previousAmount = prevMonthlyData?.amount ?? 0;
if (i > 0) {
// Get previous period data
const prevPeriod = sortedPeriods[i - 1];
const prevMonthlyData = categoryItem.monthlyData.get(prevPeriod);
const previousAmount = prevMonthlyData?.amount ?? 0;
// Update with previous amount and calculate percentage
monthlyData.previousAmount = previousAmount;
monthlyData.percentageChange = calculatePercentageChange(
monthlyData.amount,
previousAmount
);
} else {
// First period - no comparison
monthlyData.previousAmount = 0;
monthlyData.percentageChange = null;
}
}
}
// Update with previous amount and calculate percentage
monthlyData.previousAmount = previousAmount;
monthlyData.percentageChange = calculatePercentageChange(
monthlyData.amount,
previousAmount,
);
} else {
// First period - no comparison
monthlyData.previousAmount = 0;
monthlyData.percentageChange = null;
}
}
}
// Fill in missing periods with zero values
for (const categoryItem of categoryMap.values()) {
for (const period of periods) {
if (!categoryItem.monthlyData.has(period)) {
// Find previous period data for percentage calculation
const periodIndex = periods.indexOf(period);
let previousAmount = 0;
// Fill in missing periods with zero values
for (const categoryItem of categoryMap.values()) {
for (const period of periods) {
if (!categoryItem.monthlyData.has(period)) {
// Find previous period data for percentage calculation
const periodIndex = periods.indexOf(period);
let previousAmount = 0;
if (periodIndex > 0) {
const prevPeriod = periods[periodIndex - 1];
const prevData = categoryItem.monthlyData.get(prevPeriod);
previousAmount = prevData?.amount ?? 0;
}
if (periodIndex > 0) {
const prevPeriod = periods[periodIndex - 1];
const prevData = categoryItem.monthlyData.get(prevPeriod);
previousAmount = prevData?.amount ?? 0;
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
categoryItem.monthlyData.set(period, {
period,
amount: 0,
previousAmount,
percentageChange: calculatePercentageChange(0, previousAmount),
});
}
}
}
// Convert to array and sort
const categories = Array.from(categoryMap.values());
// Convert to array and sort
const categories = Array.from(categoryMap.values());
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
}
// Then by total (descending)
return b.total - a.total;
});
// Sort: despesas first (by total desc), then receitas (by total desc)
categories.sort((a, b) => {
// First by type: despesa comes before receita
if (a.type !== b.type) {
return a.type === "despesa" ? -1 : 1;
}
// Then by total (descending)
return b.total - a.total;
});
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
// Calculate grand total
let grandTotal = 0;
for (const categoryItem of categories) {
grandTotal += categoryItem.total;
}
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
return {
categories,
periods,
totals: periodTotalsMap,
grandTotal,
};
}

View File

@@ -6,47 +6,47 @@
* Monthly data for a specific category in a specific period
*/
export type MonthlyData = {
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
period: string; // Format: "YYYY-MM"
amount: number; // Total amount for this category in this period
previousAmount: number; // Amount from previous period (for comparison)
percentageChange: number | null; // Percentage change from previous period
};
/**
* Single category item in the report
*/
export type CategoryReportItem = {
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
categoryId: string;
name: string;
icon: string | null;
type: "despesa" | "receita";
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
total: number; // Total across all periods
};
/**
* Complete category report data structure
*/
export type CategoryReportData = {
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
categories: CategoryReportItem[]; // All categories with their data
periods: string[]; // All periods in the report (sorted chronologically)
totals: Map<string, number>; // Total per period across all categories
grandTotal: number; // Total of all categories and all periods
};
/**
* Filters for category report query
*/
export type CategoryReportFilters = {
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
startPeriod: string; // Format: "YYYY-MM"
endPeriod: string; // Format: "YYYY-MM"
categoryIds?: string[]; // Optional: filter by specific categories
};
/**
* Validation result for date range
*/
export type DateRangeValidation = {
isValid: boolean;
error?: string;
isValid: boolean;
error?: string;
};

View File

@@ -2,9 +2,9 @@
* Utility functions for Category Report feature
*/
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import { calculatePercentageChange } from "@/lib/utils/math";
import { currencyFormatter } from "@/lib/lancamentos/formatting-helpers";
import { calculatePercentageChange } from "@/lib/utils/math";
import { buildPeriodRange, MONTH_NAMES, parsePeriod } from "@/lib/utils/period";
import type { DateRangeValidation } from "./types";
// Re-export for convenience
@@ -18,18 +18,18 @@ export { calculatePercentageChange };
* @returns Formatted period string
*/
export function formatPeriodLabel(period: string): string {
try {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1];
try {
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1];
// Capitalize first letter and take first 3 chars
const shortMonth =
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
// Capitalize first letter and take first 3 chars
const shortMonth =
monthName.charAt(0).toUpperCase() + monthName.slice(1, 3);
return `${shortMonth}/${year}`;
} catch {
return period; // Return original if parsing fails
}
return `${shortMonth}/${year}`;
} catch {
return period; // Return original if parsing fails
}
}
/**
@@ -41,10 +41,10 @@ export function formatPeriodLabel(period: string): string {
* @returns Array of period strings in chronological order
*/
export function generatePeriodRange(
startPeriod: string,
endPeriod: string
startPeriod: string,
endPeriod: string,
): string[] {
return buildPeriodRange(startPeriod, endPeriod);
return buildPeriodRange(startPeriod, endPeriod);
}
/**
@@ -56,47 +56,47 @@ export function generatePeriodRange(
* @returns Validation result with error message if invalid
*/
export function validateDateRange(
startPeriod: string,
endPeriod: string
startPeriod: string,
endPeriod: string,
): DateRangeValidation {
try {
// Parse periods to validate format
const start = parsePeriod(startPeriod);
const end = parsePeriod(endPeriod);
try {
// Parse periods to validate format
const start = parsePeriod(startPeriod);
const end = parsePeriod(endPeriod);
// Check if end is before start
if (
end.year < start.year ||
(end.year === start.year && end.month < start.month)
) {
return {
isValid: false,
error: "A data final deve ser maior ou igual à data inicial",
};
}
// Check if end is before start
if (
end.year < start.year ||
(end.year === start.year && end.month < start.month)
) {
return {
isValid: false,
error: "A data final deve ser maior ou igual à data inicial",
};
}
// Calculate number of months between periods
const monthsDiff =
(end.year - start.year) * 12 + (end.month - start.month) + 1;
// Calculate number of months between periods
const monthsDiff =
(end.year - start.year) * 12 + (end.month - start.month) + 1;
// Check if period exceeds 24 months
if (monthsDiff > 24) {
return {
isValid: false,
error: "O período máximo permitido é de 24 meses",
};
}
// Check if period exceeds 24 months
if (monthsDiff > 24) {
return {
isValid: false,
error: "O período máximo permitido é de 24 meses",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
return { isValid: true };
} catch (error) {
return {
isValid: false,
error:
error instanceof Error
? error.message
: "Formato de período inválido. Use YYYY-MM",
};
}
}
/**
@@ -107,7 +107,7 @@ export function validateDateRange(
* @returns Formatted currency string
*/
export function formatCurrency(value: number): string {
return currencyFormatter.format(value);
return currencyFormatter.format(value);
}
/**
@@ -118,14 +118,14 @@ export function formatCurrency(value: number): string {
* @returns Formatted percentage string
*/
export function formatPercentageChange(change: number | null): string {
if (change === null) return "-";
if (change === null) return "-";
const absChange = Math.abs(change);
const sign = change >= 0 ? "+" : "-";
const absChange = Math.abs(change);
const sign = change >= 0 ? "+" : "-";
// Use one decimal place if less than 10%
const formatted =
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
// Use one decimal place if less than 10%
const formatted =
absChange < 10 ? absChange.toFixed(1) : Math.round(absChange).toString();
return `${sign}${formatted}%`;
return `${sign}${formatted}%`;
}

View File

@@ -8,127 +8,129 @@ import { z } from "zod";
* UUID schema with custom error message
*/
export const uuidSchema = (entityName: string = "ID") =>
z.string({ message: `${entityName} inválido.` }).uuid(`${entityName} inválido.`);
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));
.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)));
.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.");
.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.");
.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();
.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.",
});
.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();
.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));
.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));
.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}.`);
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.");
.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.");
.number({ message: "Informe o valor." })
.positive("Informe um valor maior que zero.");

View File

@@ -5,19 +5,19 @@
import { z } from "zod";
export const inboxItemSchema = z.object({
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
sourceAppName: z.string().optional(),
originalTitle: z.string().optional(),
originalText: z.string().min(1, "originalText é obrigatório"),
notificationTimestamp: z.string().transform((val) => new Date(val)),
parsedName: z.string().optional(),
parsedAmount: z.coerce.number().optional(),
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
clientId: z.string().optional(), // ID local do app para rastreamento
sourceApp: z.string().min(1, "sourceApp é obrigatório"),
sourceAppName: z.string().optional(),
originalTitle: z.string().optional(),
originalText: z.string().min(1, "originalText é obrigatório"),
notificationTimestamp: z.string().transform((val) => new Date(val)),
parsedName: z.string().optional(),
parsedAmount: z.coerce.number().optional(),
parsedTransactionType: z.enum(["Despesa", "Receita"]).optional(),
clientId: z.string().optional(), // ID local do app para rastreamento
});
export const inboxBatchSchema = z.object({
items: z.array(inboxItemSchema).min(1).max(50),
items: z.array(inboxItemSchema).min(1).max(50),
});
export type InboxItemInput = z.infer<typeof inboxItemSchema>;

View File

@@ -4,30 +4,30 @@ 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",
},
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;
@@ -36,29 +36,29 @@ export type InsightCategoryId = keyof typeof INSIGHT_CATEGORIES;
* Schema para item individual de insight
*/
export const InsightItemSchema = z.object({
text: z.string().min(1),
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),
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),
month: z.string().regex(/^\d{4}-\d{2}$/), // YYYY-MM
generatedAt: z.string(), // ISO datetime
categories: z.array(InsightCategorySchema).length(4),
});
/**

View File

@@ -1,269 +1,269 @@
import { lancamentos, pagadores, categorias, contas } from "@/db/schema";
import {
and,
count,
desc,
eq,
gte,
ilike,
isNull,
lte,
ne,
not,
or,
sql,
sum,
} from "drizzle-orm";
import { 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 {
ACCOUNT_AUTO_INVOICE_NOTE_PREFIX,
INITIAL_BALANCE_NOTE,
} from "@/lib/accounts/constants";
import { getPreviousPeriod } from "@/lib/utils/period";
import { safeToNumber } from "@/lib/utils/number";
import {
and,
eq,
sum,
gte,
lte,
count,
desc,
sql,
or,
isNull,
not,
ilike,
ne,
} from "drizzle-orm";
import { getPreviousPeriod } from "@/lib/utils/period";
const DESPESA = "Despesa";
const TRANSFERENCIA = "Transferência";
export type EstablishmentData = {
name: string;
count: number;
totalAmount: number;
avgAmount: number;
categories: { name: string; count: number }[];
name: string;
count: number;
totalAmount: number;
avgAmount: number;
categories: { name: string; count: number }[];
};
export type TopCategoryData = {
id: string;
name: string;
icon: string | null;
totalAmount: number;
transactionCount: number;
id: string;
name: string;
icon: string | null;
totalAmount: number;
transactionCount: number;
};
export type TopEstabelecimentosData = {
establishments: EstablishmentData[];
topCategories: TopCategoryData[];
summary: {
totalEstablishments: number;
totalTransactions: number;
totalSpent: number;
avgPerTransaction: number;
mostFrequent: string | null;
highestSpending: string | null;
};
periodLabel: string;
establishments: EstablishmentData[];
topCategories: TopCategoryData[];
summary: {
totalEstablishments: number;
totalTransactions: number;
totalSpent: number;
avgPerTransaction: number;
mostFrequent: string | null;
highestSpending: string | null;
};
periodLabel: string;
};
export type PeriodFilter = "3" | "6" | "12";
function buildPeriodRange(currentPeriod: string, months: number): string[] {
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < months; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
return periods;
const periods: string[] = [];
let p = currentPeriod;
for (let i = 0; i < months; i++) {
periods.unshift(p);
p = getPreviousPeriod(p);
}
return periods;
}
export async function fetchTopEstabelecimentosData(
userId: string,
currentPeriod: string,
periodFilter: PeriodFilter = "6",
userId: string,
currentPeriod: string,
periodFilter: PeriodFilter = "6",
): Promise<TopEstabelecimentosData> {
const months = parseInt(periodFilter, 10);
const periods = buildPeriodRange(currentPeriod, months);
const startPeriod = periods[0];
const months = parseInt(periodFilter, 10);
const periods = buildPeriodRange(currentPeriod, months);
const startPeriod = periods[0];
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
.select({
name: lancamentos.name,
count: count().as("count"),
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.name)
.orderBy(desc(sql`count`))
.limit(50);
// Fetch establishments with transaction count and total amount
const establishmentsData = await db
.select({
name: lancamentos.name,
count: count().as("count"),
totalAmount: sum(lancamentos.amount).as("total"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
ne(lancamentos.transactionType, TRANSFERENCIA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.name)
.orderBy(desc(sql`count`))
.limit(50);
// Fetch categories for each establishment
const establishmentNames = establishmentsData.map(
(e: (typeof establishmentsData)[0]) => e.name,
);
// Fetch categories for each establishment
const _establishmentNames = establishmentsData.map(
(e: (typeof establishmentsData)[0]) => e.name,
);
const categoriesByEstablishment = await db
.select({
establishmentName: lancamentos.name,
categoriaId: lancamentos.categoriaId,
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.name, lancamentos.categoriaId);
const categoriesByEstablishment = await db
.select({
establishmentName: lancamentos.name,
categoriaId: lancamentos.categoriaId,
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
),
)
.groupBy(lancamentos.name, lancamentos.categoriaId);
// Fetch all category names
const allCategories = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(eq(categorias.userId, userId));
// Fetch all category names
const allCategories = await db
.select({
id: categorias.id,
name: categorias.name,
icon: categorias.icon,
})
.from(categorias)
.where(eq(categorias.userId, userId));
type CategoryInfo = { id: string; name: string; icon: string | null };
const categoryMap = new Map<string, CategoryInfo>(
allCategories.map((c): [string, CategoryInfo] => [c.id, c as CategoryInfo]),
);
type CategoryInfo = { id: string; name: string; icon: string | null };
const categoryMap = new Map<string, CategoryInfo>(
allCategories.map((c): [string, CategoryInfo] => [c.id, c as CategoryInfo]),
);
// Build establishment data with categories
type EstablishmentRow = (typeof establishmentsData)[0];
type CategoryByEstRow = (typeof categoriesByEstablishment)[0];
// Build establishment data with categories
type EstablishmentRow = (typeof establishmentsData)[0];
type CategoryByEstRow = (typeof categoriesByEstablishment)[0];
const establishments: EstablishmentData[] = establishmentsData.map(
(est: EstablishmentRow) => {
const cnt = Number(est.count) || 0;
const total = Math.abs(safeToNumber(est.totalAmount));
const establishments: EstablishmentData[] = establishmentsData.map(
(est: EstablishmentRow) => {
const cnt = Number(est.count) || 0;
const total = Math.abs(safeToNumber(est.totalAmount));
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoriaId,
)
.map((c: CategoryByEstRow) => ({
name: categoryMap.get(c.categoriaId!)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
.sort(
(
a: { name: string; count: number },
b: { name: string; count: number },
) => b.count - a.count,
)
.slice(0, 3);
const estCategories = categoriesByEstablishment
.filter(
(c: CategoryByEstRow) =>
c.establishmentName === est.name && c.categoriaId,
)
.map((c: CategoryByEstRow) => ({
name: categoryMap.get(c.categoriaId!)?.name || "Sem categoria",
count: Number(c.count) || 0,
}))
.sort(
(
a: { name: string; count: number },
b: { name: string; count: number },
) => b.count - a.count,
)
.slice(0, 3);
return {
name: est.name,
count: cnt,
totalAmount: total,
avgAmount: cnt > 0 ? total / cnt : 0,
categories: estCategories,
};
},
);
return {
name: est.name,
count: cnt,
totalAmount: total,
avgAmount: cnt > 0 ? total / cnt : 0,
categories: estCategories,
};
},
);
// Fetch top categories by spending
const topCategoriesData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.categoriaId)
.orderBy(sql`total ASC`)
.limit(10);
// Fetch top categories by spending
const topCategoriesData = await db
.select({
categoriaId: lancamentos.categoriaId,
totalAmount: sum(lancamentos.amount).as("total"),
count: count().as("count"),
})
.from(lancamentos)
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
.leftJoin(contas, eq(lancamentos.contaId, contas.id))
.where(
and(
eq(lancamentos.userId, userId),
gte(lancamentos.period, startPeriod),
lte(lancamentos.period, currentPeriod),
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
eq(lancamentos.transactionType, DESPESA),
or(
isNull(lancamentos.note),
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
),
or(
ne(lancamentos.note, INITIAL_BALANCE_NOTE),
isNull(contas.excludeInitialBalanceFromIncome),
eq(contas.excludeInitialBalanceFromIncome, false),
),
),
)
.groupBy(lancamentos.categoriaId)
.orderBy(sql`total ASC`)
.limit(10);
type TopCategoryRow = (typeof topCategoriesData)[0];
type TopCategoryRow = (typeof topCategoriesData)[0];
const topCategories: TopCategoryData[] = topCategoriesData
.filter((c: TopCategoryRow) => c.categoriaId)
.map((cat: TopCategoryRow) => {
const catInfo = categoryMap.get(cat.categoriaId!);
return {
id: cat.categoriaId!,
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
totalAmount: Math.abs(safeToNumber(cat.totalAmount)),
transactionCount: Number(cat.count) || 0,
};
});
const topCategories: TopCategoryData[] = topCategoriesData
.filter((c: TopCategoryRow) => c.categoriaId)
.map((cat: TopCategoryRow) => {
const catInfo = categoryMap.get(cat.categoriaId!);
return {
id: cat.categoriaId!,
name: catInfo?.name || "Sem categoria",
icon: catInfo?.icon || null,
totalAmount: Math.abs(safeToNumber(cat.totalAmount)),
transactionCount: Number(cat.count) || 0,
};
});
// Calculate summary
const totalTransactions = establishments.reduce((acc, e) => acc + e.count, 0);
const totalSpent = establishments.reduce((acc, e) => acc + e.totalAmount, 0);
// Calculate summary
const totalTransactions = establishments.reduce((acc, e) => acc + e.count, 0);
const totalSpent = establishments.reduce((acc, e) => acc + e.totalAmount, 0);
const mostFrequent =
establishments.length > 0 ? establishments[0].name : null;
const mostFrequent =
establishments.length > 0 ? establishments[0].name : null;
const sortedBySpending = [...establishments].sort(
(a, b) => b.totalAmount - a.totalAmount,
);
const highestSpending =
sortedBySpending.length > 0 ? sortedBySpending[0].name : null;
const sortedBySpending = [...establishments].sort(
(a, b) => b.totalAmount - a.totalAmount,
);
const highestSpending =
sortedBySpending.length > 0 ? sortedBySpending[0].name : null;
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
const periodLabel =
months === 3
? "Últimos 3 meses"
: months === 6
? "Últimos 6 meses"
: "Últimos 12 meses";
return {
establishments,
topCategories,
summary: {
totalEstablishments: establishments.length,
totalTransactions,
totalSpent,
avgPerTransaction:
totalTransactions > 0 ? totalSpent / totalTransactions : 0,
mostFrequent,
highestSpending,
},
periodLabel,
};
return {
establishments,
topCategories,
summary: {
totalEstablishments: establishments.length,
totalTransactions,
totalSpent,
avgPerTransaction:
totalTransactions > 0 ? totalSpent / totalTransactions : 0,
mostFrequent,
highestSpending,
},
periodLabel,
};
}

View File

@@ -1,168 +1,168 @@
export type Operator = "add" | "subtract" | "multiply" | "divide";
export const OPERATOR_SYMBOLS: Record<Operator, string> = {
add: "+",
subtract: "-",
multiply: "×",
divide: "÷",
add: "+",
subtract: "-",
multiply: "×",
divide: "÷",
};
export function formatNumber(value: number): string {
if (!Number.isFinite(value)) {
return "Erro";
}
if (!Number.isFinite(value)) {
return "Erro";
}
const rounded = Number(Math.round(value * 1e10) / 1e10);
return rounded.toString();
const rounded = Number(Math.round(value * 1e10) / 1e10);
return rounded.toString();
}
export function formatLocaleValue(rawValue: string): string {
if (rawValue === "Erro") {
return rawValue;
}
if (rawValue === "Erro") {
return rawValue;
}
const isNegative = rawValue.startsWith("-");
const unsignedValue = isNegative ? rawValue.slice(1) : rawValue;
const isNegative = rawValue.startsWith("-");
const unsignedValue = isNegative ? rawValue.slice(1) : rawValue;
if (unsignedValue === "") {
return isNegative ? "-0" : "0";
}
if (unsignedValue === "") {
return isNegative ? "-0" : "0";
}
const hasDecimalSeparator = unsignedValue.includes(".");
const [integerPartRaw, decimalPartRaw] = unsignedValue.split(".");
const hasDecimalSeparator = unsignedValue.includes(".");
const [integerPartRaw, decimalPartRaw] = unsignedValue.split(".");
const integerPart = integerPartRaw || "0";
const decimalPart = hasDecimalSeparator ? (decimalPartRaw ?? "") : undefined;
const integerPart = integerPartRaw || "0";
const decimalPart = hasDecimalSeparator ? (decimalPartRaw ?? "") : undefined;
const numericInteger = Number(integerPart);
const formattedInteger = Number.isFinite(numericInteger)
? numericInteger.toLocaleString("pt-BR")
: integerPart;
const numericInteger = Number(integerPart);
const formattedInteger = Number.isFinite(numericInteger)
? numericInteger.toLocaleString("pt-BR")
: integerPart;
if (decimalPart === undefined) {
return `${isNegative ? "-" : ""}${formattedInteger}`;
}
if (decimalPart === undefined) {
return `${isNegative ? "-" : ""}${formattedInteger}`;
}
return `${isNegative ? "-" : ""}${formattedInteger},${decimalPart}`;
return `${isNegative ? "-" : ""}${formattedInteger},${decimalPart}`;
}
export function performOperation(
a: number,
b: number,
operator: Operator,
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;
}
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 trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/-?[\d.,\s]+/);
if (!match) {
return null;
}
const match = trimmed.match(/-?[\d.,\s]+/);
if (!match) {
return null;
}
let extracted = match[0].replace(/\s+/g, "");
if (!extracted) {
return null;
}
let extracted = match[0].replace(/\s+/g, "");
if (!extracted) {
return null;
}
const isNegative = extracted.startsWith("-");
if (isNegative) {
extracted = extracted.slice(1);
}
const isNegative = extracted.startsWith("-");
if (isNegative) {
extracted = extracted.slice(1);
}
extracted = extracted.replace(/[^\d.,]/g, "");
if (!extracted) {
return null;
}
extracted = extracted.replace(/[^\d.,]/g, "");
if (!extracted) {
return null;
}
const countOccurrences = (char: string) =>
(extracted.match(new RegExp(`\\${char}`, "g")) ?? []).length;
const countOccurrences = (char: string) =>
(extracted.match(new RegExp(`\\${char}`, "g")) ?? []).length;
const hasComma = extracted.includes(",");
const hasDot = extracted.includes(".");
const hasComma = extracted.includes(",");
const hasDot = extracted.includes(".");
let decimalSeparator: "," | "." | null = null;
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;
}
}
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 = "";
let integerPart = extracted;
let decimalPart = "";
if (decimalSeparator) {
const decimalIndex = extracted.lastIndexOf(decimalSeparator);
integerPart = extracted.slice(0, decimalIndex);
decimalPart = extracted.slice(decimalIndex + 1);
}
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, "");
integerPart = integerPart.replace(/[^\d]/g, "");
decimalPart = decimalPart.replace(/[^\d]/g, "");
if (!integerPart) {
integerPart = "0";
}
if (!integerPart) {
integerPart = "0";
}
let normalized = integerPart;
if (decimalPart) {
normalized = `${integerPart}.${decimalPart}`;
}
let normalized = integerPart;
if (decimalPart) {
normalized = `${integerPart}.${decimalPart}`;
}
if (isNegative && Number(normalized) !== 0) {
normalized = `-${normalized}`;
}
if (isNegative && Number(normalized) !== 0) {
normalized = `-${normalized}`;
}
if (!/^(-?\d+(\.\d+)?)$/.test(normalized)) {
return null;
}
if (!/^(-?\d+(\.\d+)?)$/.test(normalized)) {
return null;
}
const numericValue = Number(normalized);
if (!Number.isFinite(numericValue)) {
return null;
}
const numericValue = Number(normalized);
if (!Number.isFinite(numericValue)) {
return null;
}
return normalized;
return normalized;
}

View File

@@ -3,44 +3,44 @@
* Usadas para colorir ícones e backgrounds de categorias
*/
export const CATEGORY_COLORS = [
"#ef4444", // red
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#6366f1", // indigo
"#84cc16", // lime
"#ef4444", // red
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#14b8a6", // teal
"#f97316", // orange
"#6366f1", // indigo
"#84cc16", // lime
] as const;
/**
* Retorna a cor para um índice específico (com ciclo)
*/
export function getCategoryColor(index: number): string {
return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
}
/**
* Retorna a cor de background com transparência
*/
export function getCategoryBgColor(index: number): string {
const color = getCategoryColor(index);
return `${color}15`;
const color = getCategoryColor(index);
return `${color}15`;
}
/**
* Gera iniciais a partir de um nome
*/
export function buildCategoryInitials(value: string): string {
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CT";
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
const parts = value.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "CT";
if (parts.length === 1) {
const firstPart = parts[0];
return firstPart ? firstPart.slice(0, 2).toUpperCase() : "CT";
}
const firstChar = parts[0]?.[0] ?? "";
const secondChar = parts[1]?.[0] ?? "";
return `${firstChar}${secondChar}`.toUpperCase() || "CT";
}

View File

@@ -8,11 +8,11 @@
* @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;
}
if (value === null) {
return null;
}
return (Math.round(value * 100) / 100).toFixed(2);
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
@@ -21,7 +21,7 @@ export function formatDecimalForDb(value: number | null): string | null {
* @returns Formatted string with 2 decimal places
*/
export function formatDecimalForDbRequired(value: number): string {
return (Math.round(value * 100) / 100).toFixed(2);
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
@@ -30,7 +30,7 @@ export function formatDecimalForDbRequired(value: number): string {
* @returns Normalized string with period as decimal separator
*/
export function normalizeDecimalInput(value: string): string {
return value.replace(/\s/g, "").replace(",", ".");
return value.replace(/\s/g, "").replace(",", ".");
}
/**
@@ -39,11 +39,11 @@ export function normalizeDecimalInput(value: string): string {
* @returns Formatted string or empty string
*/
export function formatLimitInput(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) {
return "";
}
if (value === null || value === undefined || Number.isNaN(value)) {
return "";
}
return (Math.round(value * 100) / 100).toFixed(2);
return (Math.round(value * 100) / 100).toFixed(2);
}
/**
@@ -52,9 +52,9 @@ export function formatLimitInput(value?: number | null): string {
* @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";
}
if (value === null || value === undefined || Number.isNaN(value)) {
return "0.00";
}
return (Math.round(value * 100) / 100).toFixed(2);
return (Math.round(value * 100) / 100).toFixed(2);
}

View File

@@ -13,28 +13,28 @@
// ============================================================================
const WEEKDAY_NAMES = [
"Domingo",
"Segunda",
"Terça",
"Quarta",
"Quinta",
"Sexta",
"Sábado",
"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",
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
// ============================================================================
@@ -53,12 +53,12 @@ const MONTH_NAMES = [
* @returns Date object in local timezone
*/
export function parseLocalDateString(dateString: string): Date {
const [year, month, day] = dateString.split("-");
return new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
const [year, month, day] = dateString.split("-");
return new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10),
);
}
/**
@@ -66,12 +66,12 @@ export function parseLocalDateString(dateString: string): Date {
* @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();
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const day = now.getUTCDate();
return new Date(Date.UTC(year, month, day));
return new Date(Date.UTC(year, month, day));
}
/**
@@ -79,12 +79,12 @@ export function getTodayUTC(): Date {
* @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();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
return new Date(year, month, day);
return new Date(year, month, day);
}
/**
@@ -92,11 +92,11 @@ export function getTodayLocal(): Date {
* @returns Period string
*/
export function getTodayPeriodUTC(): string {
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
return `${year}-${String(month + 1).padStart(2, "0")}`;
return `${year}-${String(month + 1).padStart(2, "0")}`;
}
/**
@@ -105,11 +105,11 @@ export function getTodayPeriodUTC(): string {
* @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");
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}`;
return `${year}-${month}-${day}`;
}
/**
@@ -117,12 +117,12 @@ export function formatDateForDb(date: Date): 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");
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}`;
return `${year}-${month}-${day}`;
}
/**
@@ -130,7 +130,7 @@ export function getTodayDateString(): string {
* @returns Date object for today
*/
export function getTodayDate(): Date {
return parseLocalDateString(getTodayDateString());
return parseLocalDateString(getTodayDateString());
}
/**
@@ -138,15 +138,15 @@ export function getTodayDate(): Date {
* @returns Object with date and period
*/
export function getTodayInfo(): { date: Date; period: string } {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
return {
date: new Date(year, month, day),
period: `${year}-${String(month + 1).padStart(2, "0")}`,
};
return {
date: new Date(year, month, day),
period: `${year}-${String(month + 1).padStart(2, "0")}`,
};
}
/**
@@ -156,20 +156,20 @@ export function getTodayInfo(): { date: Date; period: string } {
* @returns New date with months added
*/
export function addMonthsToDate(value: Date, offset: number): Date {
const result = new Date(value);
const originalDay = result.getDate();
const result = new Date(value);
const originalDay = result.getDate();
result.setDate(1);
result.setMonth(result.getMonth() + offset);
result.setDate(1);
result.setMonth(result.getMonth() + offset);
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0
).getDate();
const lastDay = new Date(
result.getFullYear(),
result.getMonth() + 1,
0,
).getDate();
result.setDate(Math.min(originalDay, lastDay));
return result;
result.setDate(Math.min(originalDay, lastDay));
return result;
}
// ============================================================================
@@ -182,16 +182,16 @@ export function addMonthsToDate(value: Date, offset: number): Date {
* formatDate("2024-11-14") // "qui 14 nov"
*/
export function formatDate(value: string): string {
const parsed = parseLocalDateString(value);
const parsed = parseLocalDateString(value);
return new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
})
.format(parsed)
.replace(".", "")
.replace(" de", "");
return new Intl.DateTimeFormat("pt-BR", {
weekday: "short",
day: "2-digit",
month: "short",
})
.format(parsed)
.replace(".", "")
.replace(" de", "");
}
/**
@@ -200,12 +200,12 @@ export function formatDate(value: string): string {
* 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();
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}`;
return `${weekday}, ${day} de ${month} de ${year}`;
}
// ============================================================================
@@ -218,10 +218,10 @@ export function friendlyDate(date: Date): string {
* @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";
const hour = date.getHours();
if (hour >= 5 && hour < 12) return "Bom dia";
if (hour >= 12 && hour < 18) return "Boa tarde";
return "Boa noite";
}
// ============================================================================
@@ -234,16 +234,16 @@ export function getGreeting(date: Date = new Date()): string {
* @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),
};
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

View File

@@ -4,62 +4,62 @@ 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, "");
value
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
export const getIconComponent = (
iconName: string
iconName: string,
): ComponentType<{ className?: string }> | null => {
// Busca o ícone no objeto de ícones do Remix Icon
const icon = (RemixIcons as Record<string, unknown>)[iconName];
// 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 }>;
}
if (icon && typeof icon === "function") {
return icon as ComponentType<{ className?: string }>;
}
return null;
return null;
};
export const getConditionIcon = (condition: string): ReactNode => {
const key = normalizeKey(condition);
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 />,
};
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;
return registry[key] ?? null;
};
export const getPaymentMethodIcon = (paymentMethod: string): ReactNode => {
const key = normalizeKey(paymentMethod);
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.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
cartaodedebito: (
<RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
debito: <RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />,
prepagovrva: <RemixIcons.RiCouponLine className={ICON_CLASS} aria-hidden />,
transferenciabancaria: (
<RemixIcons.RiExchangeLine className={ICON_CLASS} aria-hidden />
),
};
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.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
cartaodedebito: (
<RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />
),
debito: <RemixIcons.RiBankCard2Line className={ICON_CLASS} aria-hidden />,
prepagovrva: <RemixIcons.RiCouponLine className={ICON_CLASS} aria-hidden />,
transferenciabancaria: (
<RemixIcons.RiExchangeLine className={ICON_CLASS} aria-hidden />
),
};
return registry[key] ?? null;
return registry[key] ?? null;
};

View File

@@ -9,20 +9,20 @@
* @returns Percentage change or null if previous is 0 and current is also 0
*/
export function calculatePercentageChange(
current: number,
previous: number
current: number,
previous: number,
): number | null {
const EPSILON = 0.01; // Considera valores menores que 1 centavo como zero
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;
}
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;
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;
// Protege contra valores absurdos (retorna null se > 1 milhão %)
return Number.isFinite(change) && Math.abs(change) < 1000000 ? change : null;
}
/**
@@ -32,11 +32,11 @@ export function calculatePercentageChange(
* @returns Percentage (0-100)
*/
export function calculatePercentage(part: number, total: number): number {
if (total === 0) {
return 0;
}
if (total === 0) {
return 0;
}
return (part / total) * 100;
return (part / total) * 100;
}
/**
@@ -46,6 +46,6 @@ export function calculatePercentage(part: number, total: number): number {
* @returns Rounded number
*/
export function roundToDecimals(value: number, decimals: number = 2): number {
const multiplier = 10 ** decimals;
return Math.round(value * multiplier) / multiplier;
const multiplier = 10 ** decimals;
return Math.round(value * multiplier) / multiplier;
}

View File

@@ -8,25 +8,22 @@
* @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;
}
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 (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
if (value === null || value === undefined) {
return defaultValue;
}
if (value === null || value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
const parsed = Number(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
/**
@@ -35,20 +32,17 @@ export function safeToNumber(
* @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);
}
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;
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
return defaultValue;
}
/**
@@ -58,17 +52,17 @@ export function safeParseInt(
* @returns Parsed float or default value
*/
export function safeParseFloat(
value: unknown,
defaultValue: number = 0
value: unknown,
defaultValue: number = 0,
): number {
if (typeof value === "number") {
return value;
}
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
return defaultValue;
return defaultValue;
}

View File

@@ -15,18 +15,18 @@
// ============================================================================
export const MONTH_NAMES = [
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
"janeiro",
"fevereiro",
"março",
"abril",
"maio",
"junho",
"julho",
"agosto",
"setembro",
"outubro",
"novembro",
"dezembro",
] as const;
export type MonthName = (typeof MONTH_NAMES)[number];
@@ -42,15 +42,15 @@ export type MonthName = (typeof MONTH_NAMES)[number];
* @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);
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}`);
}
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
throw new Error(`Período inválido: ${period}`);
}
return { year, month };
return { year, month };
}
/**
@@ -60,7 +60,7 @@ export function parsePeriod(period: string): { year: number; month: number } {
* @returns Period string in YYYY-MM format
*/
export function formatPeriod(year: number, month: number): string {
return `${year}-${String(month).padStart(2, "0")}`;
return `${year}-${String(month).padStart(2, "0")}`;
}
/**
@@ -69,12 +69,12 @@ export function formatPeriod(year: number, month: number): string {
* @returns True if valid, false otherwise
*/
export function isPeriodValid(period: string): boolean {
try {
parsePeriod(period);
return true;
} catch {
return false;
}
try {
parsePeriod(period);
return true;
} catch {
return false;
}
}
// ============================================================================
@@ -87,8 +87,8 @@ export function isPeriodValid(period: string): boolean {
* getCurrentPeriod() // "2025-11"
*/
export function getCurrentPeriod(): string {
const now = new Date();
return formatPeriod(now.getFullYear(), now.getMonth() + 1);
const now = new Date();
return formatPeriod(now.getFullYear(), now.getMonth() + 1);
}
/**
@@ -97,13 +97,13 @@ export function getCurrentPeriod(): string {
* @returns Previous period string
*/
export function getPreviousPeriod(period: string): string {
const { year, month } = parsePeriod(period);
const { year, month } = parsePeriod(period);
if (month === 1) {
return formatPeriod(year - 1, 12);
}
if (month === 1) {
return formatPeriod(year - 1, 12);
}
return formatPeriod(year, month - 1);
return formatPeriod(year, month - 1);
}
/**
@@ -112,13 +112,13 @@ export function getPreviousPeriod(period: string): string {
* @returns Next period string
*/
export function getNextPeriod(period: string): string {
const { year, month } = parsePeriod(period);
const { year, month } = parsePeriod(period);
if (month === 12) {
return formatPeriod(year + 1, 1);
}
if (month === 12) {
return formatPeriod(year + 1, 1);
}
return formatPeriod(year, month + 1);
return formatPeriod(year, month + 1);
}
/**
@@ -128,15 +128,15 @@ export function getNextPeriod(period: string): string {
* @returns New period string
*/
export function addMonthsToPeriod(period: string, offset: number): string {
const { year: baseYear, month: baseMonth } = parsePeriod(period);
const { year: baseYear, month: baseMonth } = parsePeriod(period);
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const date = new Date(baseYear, baseMonth - 1, 1);
date.setMonth(date.getMonth() + offset);
const nextYear = date.getFullYear();
const nextMonth = date.getMonth() + 1;
const nextYear = date.getFullYear();
const nextMonth = date.getMonth() + 1;
return formatPeriod(nextYear, nextMonth);
return formatPeriod(nextYear, nextMonth);
}
/**
@@ -146,13 +146,13 @@ export function addMonthsToPeriod(period: string, offset: number): string {
* @returns Array of period strings
*/
export function getLastPeriods(current: string, length: number): string[] {
const periods: string[] = [];
const periods: string[] = [];
for (let offset = length - 1; offset >= 0; offset -= 1) {
periods.push(addMonthsToPeriod(current, -offset));
}
for (let offset = length - 1; offset >= 0; offset -= 1) {
periods.push(addMonthsToPeriod(current, -offset));
}
return periods;
return periods;
}
// ============================================================================
@@ -166,8 +166,8 @@ export function getLastPeriods(current: string, length: number): string[] {
* @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;
if (a === b) return 0;
return a < b ? -1 : 1;
}
/**
@@ -177,34 +177,34 @@ export function comparePeriods(a: string, b: string): number {
* @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 [startKey, endKey] =
comparePeriods(start, end) <= 0 ? [start, end] : [end, start];
const startParts = parsePeriod(startKey);
const endParts = parsePeriod(endKey);
const startParts = parsePeriod(startKey);
const endParts = parsePeriod(endKey);
const items: string[] = [];
let currentYear = startParts.year;
let currentMonth = startParts.month;
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));
while (
currentYear < endParts.year ||
(currentYear === endParts.year && currentMonth <= endParts.month)
) {
items.push(formatPeriod(currentYear, currentMonth));
if (currentYear === endParts.year && currentMonth === endParts.month) {
break;
}
if (currentYear === endParts.year && currentMonth === endParts.month) {
break;
}
currentMonth += 1;
if (currentMonth > 12) {
currentMonth = 1;
currentYear += 1;
}
}
currentMonth += 1;
if (currentMonth > 12) {
currentMonth = 1;
currentYear += 1;
}
}
return items;
return items;
}
// ============================================================================
@@ -214,12 +214,12 @@ export function buildPeriodRange(start: string, end: string): string[] {
const MONTH_MAP = new Map(MONTH_NAMES.map((name, index) => [name, index]));
const normalize = (value: string | null | undefined) =>
(value ?? "").trim().toLowerCase();
(value ?? "").trim().toLowerCase();
export type ParsedPeriod = {
period: string;
monthName: string;
year: number;
period: string;
monthName: string;
year: number;
};
/**
@@ -229,34 +229,34 @@ export type ParsedPeriod = {
* @returns Parsed period object
*/
export function parsePeriodParam(
periodParam: string | null | undefined,
referenceDate = new Date()
periodParam: string | null | undefined,
referenceDate = new Date(),
): ParsedPeriod {
const fallbackMonthIndex = referenceDate.getMonth();
const fallbackYear = referenceDate.getFullYear();
const fallbackPeriod = formatPeriod(fallbackYear, fallbackMonthIndex + 1);
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 };
}
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);
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 };
}
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,
};
const monthName = MONTH_NAMES[monthIndex];
return {
period: formatPeriod(parsedYear, monthIndex + 1),
monthName,
year: parsedYear,
};
}
/**
@@ -266,7 +266,7 @@ export function parsePeriodParam(
* @returns URL param string in "mes-ano" format
*/
export function formatPeriodParam(monthName: string, year: number): string {
return `${normalize(monthName)}-${year}`;
return `${normalize(monthName)}-${year}`;
}
/**
@@ -276,21 +276,21 @@ export function formatPeriodParam(monthName: string, year: number): string {
* 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;
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;
}
if (
Number.isNaN(year) ||
Number.isNaN(monthIndex) ||
monthIndex < 0 ||
monthIndex > 11
) {
return period;
}
const monthName = MONTH_NAMES[monthIndex] ?? "";
return formatPeriodParam(monthName, year);
const monthName = MONTH_NAMES[monthIndex] ?? "";
return formatPeriodParam(monthName, year);
}
// ============================================================================
@@ -298,9 +298,9 @@ export function formatPeriodForUrl(period: string): string {
// ============================================================================
function capitalize(value: string): string {
return value.length > 0
? value[0]?.toUpperCase().concat(value.slice(1))
: value;
return value.length > 0
? value[0]?.toUpperCase().concat(value.slice(1))
: value;
}
/**
@@ -309,9 +309,9 @@ function capitalize(value: string): string {
* 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}`;
const { year, month } = parsePeriod(period);
const monthName = MONTH_NAMES[month - 1] ?? "";
return `${capitalize(monthName)} de ${year}`;
}
/**
@@ -320,7 +320,7 @@ export function displayPeriod(period: string): string {
* formatMonthLabel("2024-01") // "Janeiro de 2024"
*/
export function formatMonthLabel(period: string): string {
return displayPeriod(period);
return displayPeriod(period);
}
// ============================================================================
@@ -334,24 +334,23 @@ export function formatMonthLabel(period: string): string {
* derivePeriodFromDate() // current period
*/
export function derivePeriodFromDate(value?: string | null): string {
if (!value) {
return getCurrentPeriod();
}
if (!value) {
return getCurrentPeriod();
}
// Parse date string as local date to avoid timezone issues
// IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
const [year, month, day] = value.split("-");
const date = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10)
);
// Parse date string as local date to avoid timezone issues
// IMPORTANT: new Date("2025-11-25") treats the date as UTC midnight,
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
const [year, month, day] = value.split("-");
const date = new Date(
Number.parseInt(year ?? "0", 10),
Number.parseInt(month ?? "1", 10) - 1,
Number.parseInt(day ?? "1", 10),
);
if (Number.isNaN(date.getTime())) {
return getCurrentPeriod();
}
if (Number.isNaN(date.getTime())) {
return getCurrentPeriod();
}
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
}

View File

@@ -8,10 +8,10 @@
* @returns Trimmed string or null if empty
*/
export function normalizeOptionalString(
value: string | null | undefined
value: string | null | undefined,
): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
/**
@@ -20,7 +20,7 @@ export function normalizeOptionalString(
* @returns Filename without path
*/
export function normalizeFilePath(path: string | null | undefined): string {
return path?.split("/").filter(Boolean).pop() ?? "";
return path?.split("/").filter(Boolean).pop() ?? "";
}
/**
@@ -29,7 +29,7 @@ export function normalizeFilePath(path: string | null | undefined): string {
* @returns String with normalized whitespace
*/
export function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
return value.replace(/\s+/g, " ").trim();
}
/**
@@ -38,6 +38,6 @@ export function normalizeWhitespace(value: string): string {
* @returns Trimmed icon string or null
*/
export function normalizeIconInput(icon?: string | null): string | null {
const trimmed = icon?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
const trimmed = icon?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}

View File

@@ -4,7 +4,7 @@
* This module contains UI-related utilities, primarily for className manipulation.
*/
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
@@ -13,5 +13,5 @@ import { twMerge } from "tailwind-merge";
* @returns Merged className string
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}