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:
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? "";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
36
lib/db.ts
36
lib/db.ts
@@ -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 };
|
||||
|
||||
@@ -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}$/;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(" ");
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(" ");
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}%`;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user