mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
refactor(core): move app para src e padroniza estrutura
This commit is contained in:
21
src/shared/lib/accounts/constants.ts
Normal file
21
src/shared/lib/accounts/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
LANCAMENTO_CONDITIONS,
|
||||
LANCAMENTO_PAYMENT_METHODS,
|
||||
LANCAMENTO_TRANSACTION_TYPES,
|
||||
} from "@/features/transactions/constants";
|
||||
|
||||
export const INITIAL_BALANCE_CATEGORY_NAME = "Saldo inicial";
|
||||
export const INITIAL_BALANCE_NOTE = "saldo inicial";
|
||||
|
||||
export const INITIAL_BALANCE_CONDITION =
|
||||
LANCAMENTO_CONDITIONS.find((condition) => condition === "À vista") ??
|
||||
"À vista";
|
||||
export const INITIAL_BALANCE_PAYMENT_METHOD =
|
||||
LANCAMENTO_PAYMENT_METHODS.find((method) => method === "Pix") ?? "Pix";
|
||||
export const INITIAL_BALANCE_TRANSACTION_TYPE =
|
||||
LANCAMENTO_TRANSACTION_TYPES.find((type) => type === "Receita") ?? "Receita";
|
||||
|
||||
export const ACCOUNT_AUTO_INVOICE_NOTE_PREFIX = "AUTO_FATURA:";
|
||||
|
||||
export const buildInvoicePaymentNote = (cardId: string, period: string) =>
|
||||
`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}${cardId}:${period}`;
|
||||
65
src/shared/lib/actions/helpers.ts
Normal file
65
src/shared/lib/actions/helpers.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
export type { ActionResult } from "@/shared/lib/types/actions";
|
||||
|
||||
import type { ActionResult } from "@/shared/lib/types/actions";
|
||||
import { errorResult } from "@/shared/lib/types/actions";
|
||||
|
||||
/**
|
||||
* Handles errors in server actions consistently
|
||||
* @param error - The error to handle
|
||||
* @returns ActionResult with error message
|
||||
*/
|
||||
export function handleActionError(error: unknown): ActionResult {
|
||||
if (error instanceof z.ZodError) {
|
||||
return errorResult(error.issues[0]?.message ?? "Dados inválidos.");
|
||||
}
|
||||
|
||||
console.error("[ActionError]", error);
|
||||
return errorResult("Ocorreu um erro inesperado. Tente novamente.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for revalidation after mutations
|
||||
*/
|
||||
export const revalidateConfig = {
|
||||
cartoes: ["/cards", "/accounts", "/transactions"],
|
||||
contas: ["/accounts", "/transactions"],
|
||||
categorias: ["/categories"],
|
||||
estabelecimentos: ["/reports/establishments", "/transactions"],
|
||||
orcamentos: ["/budgets"],
|
||||
pagadores: ["/payers"],
|
||||
anotacoes: ["/notes", "/notes/arquivadas", "/dashboard"],
|
||||
lancamentos: ["/transactions", "/accounts"],
|
||||
inbox: ["/inbox", "/transactions", "/dashboard"],
|
||||
recorrentes: ["/transactions", "/dashboard"],
|
||||
} as const;
|
||||
|
||||
/** Entities whose mutations should invalidate the dashboard cache */
|
||||
const DASHBOARD_ENTITIES: ReadonlySet<string> = new Set([
|
||||
"lancamentos",
|
||||
"contas",
|
||||
"cartoes",
|
||||
"orcamentos",
|
||||
"pagadores",
|
||||
"anotacoes",
|
||||
"inbox",
|
||||
"recorrentes",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Revalidates paths for a specific entity.
|
||||
* Also invalidates the dashboard "use cache" tag for financial entities.
|
||||
* @param entity - The entity type
|
||||
*/
|
||||
export function revalidateForEntity(
|
||||
entity: keyof typeof revalidateConfig,
|
||||
): void {
|
||||
revalidateConfig[entity].forEach((path) => revalidatePath(path));
|
||||
|
||||
// Invalidate dashboard cache for financial mutations
|
||||
if (DASHBOARD_ENTITIES.has(entity)) {
|
||||
revalidateTag("dashboard", "max");
|
||||
}
|
||||
}
|
||||
229
src/shared/lib/auth/api-token.ts
Normal file
229
src/shared/lib/auth/api-token.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"BETTER_AUTH_SECRET is required. Set it in your .env file.",
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
const ACCESS_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds
|
||||
const REFRESH_TOKEN_EXPIRY = 90 * 24 * 60 * 60; // 90 days in seconds
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface JwtPayload {
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base64URL encode a string
|
||||
*/
|
||||
function base64UrlEncode(str: string): string {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HMAC-SHA256 signature
|
||||
*/
|
||||
function createSignature(data: string): string {
|
||||
return crypto
|
||||
.createHmac("sha256", getJwtSecret())
|
||||
.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);
|
||||
|
||||
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}`);
|
||||
|
||||
return `${headerEncoded}.${payloadEncoded}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
* @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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const payload: JwtPayload = JSON.parse(base64UrlDecode(payloadEncoded));
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOKEN GENERATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate a random token ID
|
||||
*/
|
||||
export function generateTokenId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token using SHA-256
|
||||
*/
|
||||
export function hashToken(token: string): string {
|
||||
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)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
if (!payload || payload.type !== "api_refresh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
return { accessToken, expiresAt };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
23
src/shared/lib/auth/client.ts
Normal file
23
src/shared/lib/auth/client.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { passkeyClient } from "@better-auth/passkey/client";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
const baseURL = process.env.BETTER_AUTH_URL?.replace(/\/$/, "");
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
plugins: [passkeyClient()],
|
||||
});
|
||||
|
||||
/**
|
||||
* Indica se o login com Google está habilitado.
|
||||
* Verifica se as credenciais do Google OAuth estão configuradas.
|
||||
*
|
||||
* IMPORTANTE: Como variáveis de ambiente sem prefixo NEXT_PUBLIC_ não estão
|
||||
* disponíveis no cliente, esta verificação deve ser feita no servidor.
|
||||
* Por isso, sempre retornamos true aqui e a validação real acontece no servidor.
|
||||
*
|
||||
* Para desabilitar o Google OAuth, remova ou deixe vazias as variáveis:
|
||||
* - GOOGLE_CLIENT_ID
|
||||
* - GOOGLE_CLIENT_SECRET
|
||||
*/
|
||||
export const googleSignInAvailable = true;
|
||||
147
src/shared/lib/auth/config.ts
Normal file
147
src/shared/lib/auth/config.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import type { GoogleProfile } from "better-auth/social-providers";
|
||||
import { seedDefaultCategoriesForUser } from "@/shared/lib/categories/defaults";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
import { ensureDefaultPagadorForUser } from "@/shared/lib/payers/defaults";
|
||||
import { normalizeNameFromEmail } from "@/shared/lib/payers/utils";
|
||||
|
||||
// ============================================================================
|
||||
// GOOGLE OAUTH CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
||||
|
||||
/**
|
||||
* Extrai nome do usuário do perfil do Google com fallback hierárquico:
|
||||
* 1. profile.name (nome completo)
|
||||
* 2. profile.given_name + profile.family_name
|
||||
* 3. Nome extraído do email
|
||||
* 4. "Usuário" (fallback final)
|
||||
*/
|
||||
function getNameFromGoogleProfile(profile: GoogleProfile): string {
|
||||
const fullName = profile.name?.trim();
|
||||
if (fullName) return fullName;
|
||||
|
||||
const fromGivenFamily = [profile.given_name, profile.family_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (fromGivenFamily) return fromGivenFamily;
|
||||
|
||||
const fromEmail = profile.email
|
||||
? normalizeNameFromEmail(profile.email)
|
||||
: undefined;
|
||||
|
||||
return fromEmail ?? "Usuário";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BETTER AUTH INSTANCE
|
||||
// ============================================================================
|
||||
|
||||
export const auth = betterAuth({
|
||||
// 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]
|
||||
: [],
|
||||
|
||||
// Email/Password authentication
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
autoSignIn: 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
|
||||
},
|
||||
},
|
||||
|
||||
// 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
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins
|
||||
plugins: [
|
||||
passkey({
|
||||
rpName: "OpenMonetis",
|
||||
}),
|
||||
],
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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.",
|
||||
);
|
||||
}
|
||||
66
src/shared/lib/auth/server.ts
Normal file
66
src/shared/lib/auth/server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/shared/lib/auth/config";
|
||||
|
||||
/**
|
||||
* Cached session fetch - deduplicates auth calls within a single request.
|
||||
* Layout + page calling getUser() will only hit auth once.
|
||||
*/
|
||||
const getSessionCached = cache(async () => {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets the current authenticated user
|
||||
* @returns User object
|
||||
* @throws Redirects to /login if user is not authenticated
|
||||
*/
|
||||
export async function getUser() {
|
||||
const session = await getSessionCached();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return session.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current authenticated user ID
|
||||
* @returns User ID string
|
||||
* @throws Redirects to /login if user is not authenticated
|
||||
*/
|
||||
export async function getUserId() {
|
||||
const session = await getSessionCached();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current authenticated session
|
||||
* @returns Full session object including user
|
||||
* @throws Redirects to /login if user is not authenticated
|
||||
*/
|
||||
export async function getUserSession() {
|
||||
const session = await getSessionCached();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current session without requiring authentication
|
||||
* @returns Session object or null if not authenticated
|
||||
* @note This function does not redirect if user is not authenticated
|
||||
*/
|
||||
export async function getOptionalUserSession() {
|
||||
return getSessionCached();
|
||||
}
|
||||
143
src/shared/lib/calculator/use-calculator-keyboard.ts
Normal file
143
src/shared/lib/calculator/use-calculator-keyboard.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Operator } from "@/shared/utils/calculator";
|
||||
|
||||
type UseCalculatorKeyboardParams = {
|
||||
isOpen: boolean;
|
||||
canCopy: boolean;
|
||||
onCopy: () => void | Promise<void>;
|
||||
onPaste: () => void | Promise<void>;
|
||||
inputDigit: (digit: string) => void;
|
||||
inputDecimal: () => void;
|
||||
setNextOperator: (op: Operator) => void;
|
||||
evaluate: () => void;
|
||||
deleteLastDigit: () => void;
|
||||
reset: () => void;
|
||||
applyPercent: () => void;
|
||||
};
|
||||
|
||||
function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tagName = target.tagName;
|
||||
return (
|
||||
tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
const KEY_TO_OPERATOR: Record<string, Operator> = {
|
||||
"+": "add",
|
||||
"-": "subtract",
|
||||
"*": "multiply",
|
||||
"/": "divide",
|
||||
};
|
||||
|
||||
export function useCalculatorKeyboard({
|
||||
isOpen,
|
||||
canCopy,
|
||||
onCopy,
|
||||
onPaste,
|
||||
inputDigit,
|
||||
inputDecimal,
|
||||
setNextOperator,
|
||||
evaluate,
|
||||
deleteLastDigit,
|
||||
reset,
|
||||
applyPercent,
|
||||
}: UseCalculatorKeyboardParams) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey } = event;
|
||||
|
||||
// Ctrl/Cmd shortcuts
|
||||
if (ctrlKey || metaKey) {
|
||||
if (shouldIgnoreForEditableTarget(event.target)) return;
|
||||
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === "c" && canCopy) {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) return;
|
||||
event.preventDefault();
|
||||
void onCopy();
|
||||
} else if (lowerKey === "v") {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) return;
|
||||
if (!navigator.clipboard?.readText) return;
|
||||
event.preventDefault();
|
||||
void onPaste();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Digits
|
||||
if (key >= "0" && key <= "9") {
|
||||
event.preventDefault();
|
||||
inputDigit(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decimal
|
||||
if (key === "." || key === ",") {
|
||||
event.preventDefault();
|
||||
inputDecimal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Operators
|
||||
const op = KEY_TO_OPERATOR[key];
|
||||
if (op) {
|
||||
event.preventDefault();
|
||||
setNextOperator(op);
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate
|
||||
if (key === "Enter" || key === "=") {
|
||||
event.preventDefault();
|
||||
evaluate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace
|
||||
if (key === "Backspace") {
|
||||
event.preventDefault();
|
||||
deleteLastDigit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape resets calculator (dialog close is handled by onEscapeKeyDown)
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Percent
|
||||
if (key === "%") {
|
||||
event.preventDefault();
|
||||
applyPercent();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
isOpen,
|
||||
canCopy,
|
||||
onCopy,
|
||||
onPaste,
|
||||
inputDigit,
|
||||
inputDecimal,
|
||||
setNextOperator,
|
||||
evaluate,
|
||||
deleteLastDigit,
|
||||
reset,
|
||||
applyPercent,
|
||||
]);
|
||||
}
|
||||
379
src/shared/lib/calculator/use-calculator-state.ts
Normal file
379
src/shared/lib/calculator/use-calculator-state.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { buttonVariants } from "@/shared/components/ui/button";
|
||||
import {
|
||||
formatLocaleValue,
|
||||
formatNumber,
|
||||
normalizeClipboardNumber,
|
||||
OPERATOR_SYMBOLS,
|
||||
type Operator,
|
||||
performOperation,
|
||||
} from "@/shared/utils/calculator";
|
||||
|
||||
export type CalculatorButtonConfig = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||
colSpan?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function useCalculatorState() {
|
||||
const [display, setDisplay] = useState("0");
|
||||
const [accumulator, setAccumulator] = useState<number | null>(null);
|
||||
const [operator, setOperator] = useState<Operator | null>(null);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [history, setHistory] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const currentValue = Number(display);
|
||||
|
||||
const resultText = (() => {
|
||||
if (display === "Erro") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = formatNumber(currentValue);
|
||||
if (normalized === "Erro") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatLocaleValue(normalized);
|
||||
})();
|
||||
|
||||
const reset = () => {
|
||||
setDisplay("0");
|
||||
setAccumulator(null);
|
||||
setOperator(null);
|
||||
setOverwrite(false);
|
||||
setHistory(null);
|
||||
};
|
||||
|
||||
const inputDigit = (digit: string) => {
|
||||
// Check conditions before state updates
|
||||
const shouldReset = overwrite || display === "Erro";
|
||||
|
||||
setDisplay((prev) => {
|
||||
if (shouldReset) {
|
||||
return digit;
|
||||
}
|
||||
|
||||
if (prev === "0") {
|
||||
return digit;
|
||||
}
|
||||
|
||||
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
|
||||
const digitCount = prev.replace(/[-.]/g, "").length;
|
||||
if (digitCount >= 10) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return `${prev}${digit}`;
|
||||
});
|
||||
|
||||
// Update related states after display update
|
||||
if (shouldReset) {
|
||||
setOverwrite(false);
|
||||
setHistory(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inputDecimal = () => {
|
||||
// Check conditions before state updates
|
||||
const shouldReset = overwrite || display === "Erro";
|
||||
|
||||
setDisplay((prev) => {
|
||||
if (shouldReset) {
|
||||
return "0.";
|
||||
}
|
||||
|
||||
if (prev.includes(".")) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// Limitar a 10 dígitos antes de adicionar o ponto decimal
|
||||
const digitCount = prev.replace(/[-]/g, "").length;
|
||||
if (digitCount >= 10) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return `${prev}.`;
|
||||
});
|
||||
|
||||
// Update related states after display update
|
||||
if (shouldReset) {
|
||||
setOverwrite(false);
|
||||
setHistory(null);
|
||||
}
|
||||
};
|
||||
|
||||
const setNextOperator = (nextOperator: Operator) => {
|
||||
if (display === "Erro") {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const value = currentValue;
|
||||
|
||||
if (accumulator === null || operator === null || overwrite) {
|
||||
setAccumulator(value);
|
||||
} else {
|
||||
const result = performOperation(accumulator, value, operator);
|
||||
const formatted = formatNumber(result);
|
||||
setAccumulator(Number.isFinite(result) ? result : null);
|
||||
setDisplay(formatted);
|
||||
if (!Number.isFinite(result)) {
|
||||
setOperator(null);
|
||||
setOverwrite(true);
|
||||
setHistory(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setOperator(nextOperator);
|
||||
setOverwrite(true);
|
||||
setHistory(null);
|
||||
};
|
||||
|
||||
const evaluate = () => {
|
||||
if (operator === null || accumulator === null || display === "Erro") {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = currentValue;
|
||||
const left = formatNumber(accumulator);
|
||||
const right = formatNumber(value);
|
||||
const symbol = OPERATOR_SYMBOLS[operator];
|
||||
const operation = `${formatLocaleValue(left)} ${symbol} ${formatLocaleValue(
|
||||
right,
|
||||
)}`;
|
||||
const result = performOperation(accumulator, value, operator);
|
||||
const formatted = formatNumber(result);
|
||||
|
||||
setDisplay(formatted);
|
||||
setAccumulator(Number.isFinite(result) ? result : null);
|
||||
setOperator(null);
|
||||
setOverwrite(true);
|
||||
setHistory(operation);
|
||||
};
|
||||
|
||||
const toggleSign = () => {
|
||||
setDisplay((prev) => {
|
||||
if (prev === "Erro") {
|
||||
return prev;
|
||||
}
|
||||
if (prev.startsWith("-")) {
|
||||
return prev.slice(1);
|
||||
}
|
||||
return prev === "0" ? prev : `-${prev}`;
|
||||
});
|
||||
if (overwrite) {
|
||||
setOverwrite(false);
|
||||
setHistory(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLastDigit = () => {
|
||||
setHistory(null);
|
||||
|
||||
// Check conditions before state updates
|
||||
const isError = display === "Erro";
|
||||
|
||||
setDisplay((prev) => {
|
||||
if (prev === "Erro") {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (prev.length <= 1 || (prev.length === 2 && prev.startsWith("-"))) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
|
||||
// Update related states after display update
|
||||
if (isError) {
|
||||
setAccumulator(null);
|
||||
setOperator(null);
|
||||
setOverwrite(false);
|
||||
} else if (overwrite) {
|
||||
setOverwrite(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPercent = () => {
|
||||
setDisplay((prev) => {
|
||||
if (prev === "Erro") {
|
||||
return prev;
|
||||
}
|
||||
const value = Number(prev);
|
||||
return formatNumber(value / 100);
|
||||
});
|
||||
setOverwrite(true);
|
||||
setHistory(null);
|
||||
};
|
||||
|
||||
const expression = (() => {
|
||||
if (display === "Erro") {
|
||||
return "Erro";
|
||||
}
|
||||
|
||||
if (operator && accumulator !== null) {
|
||||
const symbol = OPERATOR_SYMBOLS[operator];
|
||||
const left = formatLocaleValue(formatNumber(accumulator));
|
||||
|
||||
if (overwrite) {
|
||||
return `${left} ${symbol}`;
|
||||
}
|
||||
|
||||
return `${left} ${symbol} ${formatLocaleValue(display)}`;
|
||||
}
|
||||
|
||||
return formatLocaleValue(display);
|
||||
})();
|
||||
|
||||
const makeOperatorHandler = (nextOperator: Operator) => () =>
|
||||
setNextOperator(nextOperator);
|
||||
|
||||
const buttons: CalculatorButtonConfig[][] = [
|
||||
[
|
||||
{ label: "C", onClick: reset, variant: "destructive" },
|
||||
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
|
||||
{ label: "%", onClick: applyPercent, variant: "secondary" },
|
||||
{
|
||||
label: "÷",
|
||||
onClick: makeOperatorHandler("divide"),
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "7", onClick: () => inputDigit("7") },
|
||||
{ label: "8", onClick: () => inputDigit("8") },
|
||||
{ label: "9", onClick: () => inputDigit("9") },
|
||||
{
|
||||
label: "×",
|
||||
onClick: makeOperatorHandler("multiply"),
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "4", onClick: () => inputDigit("4") },
|
||||
{ label: "5", onClick: () => inputDigit("5") },
|
||||
{ label: "6", onClick: () => inputDigit("6") },
|
||||
{
|
||||
label: "-",
|
||||
onClick: makeOperatorHandler("subtract"),
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
[
|
||||
{ label: "1", onClick: () => inputDigit("1") },
|
||||
{ label: "2", onClick: () => inputDigit("2") },
|
||||
{ label: "3", onClick: () => inputDigit("3") },
|
||||
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
|
||||
],
|
||||
[
|
||||
{ label: "±", onClick: toggleSign, variant: "secondary" },
|
||||
{ label: "0", onClick: () => inputDigit("0") },
|
||||
{ label: ",", onClick: inputDecimal },
|
||||
{ label: "=", onClick: evaluate, variant: "default" },
|
||||
],
|
||||
];
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (!resultText) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(resultText);
|
||||
|
||||
setCopied(true);
|
||||
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||
}
|
||||
resetCopiedTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Não foi possível copiar o resultado da calculadora.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
if (!navigator.clipboard?.readText) return;
|
||||
|
||||
try {
|
||||
const rawValue = await navigator.clipboard.readText();
|
||||
const normalized = normalizeClipboardNumber(rawValue);
|
||||
|
||||
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||
resetCopiedTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
setCopied(false);
|
||||
|
||||
if (!normalized) {
|
||||
setDisplay("Erro");
|
||||
setAccumulator(null);
|
||||
setOperator(null);
|
||||
setOverwrite(true);
|
||||
setHistory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limitar a 10 dígitos
|
||||
const digitCount = normalized.replace(/[-.]/g, "").length;
|
||||
if (digitCount > 10) {
|
||||
setDisplay("Erro");
|
||||
setAccumulator(null);
|
||||
setOperator(null);
|
||||
setOverwrite(true);
|
||||
setHistory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplay(normalized);
|
||||
setAccumulator(null);
|
||||
setOperator(null);
|
||||
setOverwrite(false);
|
||||
setHistory(null);
|
||||
} catch (error) {
|
||||
console.error("Não foi possível colar o valor na calculadora.", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
display,
|
||||
operator,
|
||||
expression,
|
||||
history,
|
||||
resultText,
|
||||
copied,
|
||||
buttons,
|
||||
inputDigit,
|
||||
inputDecimal,
|
||||
setNextOperator,
|
||||
evaluate,
|
||||
deleteLastDigit,
|
||||
reset,
|
||||
applyPercent,
|
||||
copyToClipboard,
|
||||
pasteFromClipboard,
|
||||
};
|
||||
}
|
||||
112
src/shared/lib/calculator/use-draggable-dialog.ts
Normal file
112
src/shared/lib/calculator/use-draggable-dialog.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
const MIN_VISIBLE_PX = 20;
|
||||
|
||||
function clampPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
elementWidth: number,
|
||||
elementHeight: number,
|
||||
): Position {
|
||||
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
|
||||
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
|
||||
const halfViewportWidth = window.innerWidth / 2;
|
||||
const halfViewportHeight = window.innerHeight / 2;
|
||||
const halfElementWidth = elementWidth / 2;
|
||||
const halfElementHeight = elementHeight / 2;
|
||||
|
||||
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
|
||||
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
|
||||
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
|
||||
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(x, minX), maxX),
|
||||
y: Math.min(Math.max(y, minY), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function applyPosition(el: HTMLElement, x: number, y: number) {
|
||||
if (x === 0 && y === 0) {
|
||||
el.style.translate = "";
|
||||
el.style.transform = "";
|
||||
} else {
|
||||
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
|
||||
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
|
||||
el.style.transform = "";
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraggableDialog() {
|
||||
const offset = useRef<Position>({ x: 0, y: 0 });
|
||||
const dragStart = useRef<Position | null>(null);
|
||||
const initialOffset = useRef<Position>({ x: 0, y: 0 });
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
dragStart.current = { x: e.clientX, y: e.clientY };
|
||||
initialOffset.current = { x: offset.current.x, y: offset.current.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||
if (!dragStart.current || !contentRef.current) return;
|
||||
|
||||
const dx = e.clientX - dragStart.current.x;
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
|
||||
const rawX = initialOffset.current.x + dx;
|
||||
const rawY = initialOffset.current.y + dy;
|
||||
|
||||
const el = contentRef.current;
|
||||
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
|
||||
|
||||
offset.current = clamped;
|
||||
applyPosition(el, clamped.x, clamped.y);
|
||||
}, []);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||
dragStart.current = null;
|
||||
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPointerCancel = useCallback(() => {
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
|
||||
const onLostPointerCapture = useCallback(() => {
|
||||
dragStart.current = null;
|
||||
}, []);
|
||||
|
||||
const resetPosition = useCallback(() => {
|
||||
offset.current = { x: 0, y: 0 };
|
||||
if (contentRef.current) {
|
||||
applyPosition(contentRef.current, 0, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dragHandleProps = {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onLostPointerCapture,
|
||||
style: { touchAction: "none" as const, cursor: "grab" },
|
||||
};
|
||||
|
||||
const contentRefCallback = useCallback((node: HTMLElement | null) => {
|
||||
contentRef.current = node;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dragHandleProps,
|
||||
contentRefCallback,
|
||||
resetPosition,
|
||||
};
|
||||
}
|
||||
45
src/shared/lib/cards/brand-assets.ts
Normal file
45
src/shared/lib/cards/brand-assets.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const CARD_BRAND_ASSET_BY_KEY = {
|
||||
visa: "/flags/visa.svg",
|
||||
mastercard: "/flags/mastercard.svg",
|
||||
amex: "/flags/amex.svg",
|
||||
american: "/flags/amex.svg",
|
||||
elo: "/flags/elo.svg",
|
||||
hipercard: "/flags/hipercard.svg",
|
||||
hiper: "/flags/hipercard.svg",
|
||||
} as const;
|
||||
|
||||
const CARD_BRAND_LOGO_BY_KEY = {
|
||||
visa: "/logos/visa.png",
|
||||
mastercard: "/logos/mastercard.png",
|
||||
amex: "/logos/amex.png",
|
||||
american: "/logos/amex.png",
|
||||
elo: "/logos/elo.png",
|
||||
hipercard: "/logos/hipercard.png",
|
||||
hiper: "/logos/hipercard.png",
|
||||
} as const;
|
||||
|
||||
const findMatchingCardBrandKey = (brand?: string | null) => {
|
||||
if (!brand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedBrand = brand.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
(
|
||||
Object.keys(CARD_BRAND_ASSET_BY_KEY) as Array<
|
||||
keyof typeof CARD_BRAND_ASSET_BY_KEY
|
||||
>
|
||||
).find((key) => normalizedBrand.includes(key)) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveCardBrandAsset = (brand?: string | null) => {
|
||||
const key = findMatchingCardBrandKey(brand);
|
||||
return key ? CARD_BRAND_ASSET_BY_KEY[key] : null;
|
||||
};
|
||||
|
||||
export const resolveCardBrandLogoSrc = (brand?: string | null) => {
|
||||
const key = findMatchingCardBrandKey(brand);
|
||||
return key ? CARD_BRAND_LOGO_BY_KEY[key] : null;
|
||||
};
|
||||
7
src/shared/lib/cards/constants.ts
Normal file
7
src/shared/lib/cards/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
|
||||
|
||||
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
|
||||
|
||||
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
|
||||
String(index + 1).padStart(2, "0"),
|
||||
);
|
||||
8
src/shared/lib/categories/constants.ts
Normal file
8
src/shared/lib/categories/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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",
|
||||
};
|
||||
83
src/shared/lib/categories/defaults.ts
Normal file
83
src/shared/lib/categories/defaults.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { categorias } from "@/db/schema";
|
||||
import type { CategoryType } from "@/shared/lib/categories/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
type DefaultCategory = {
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_CATEGORIES: DefaultCategory[] = [
|
||||
// Despesas
|
||||
{ name: "Alimentação", type: "despesa", icon: "RiRestaurant2Line" },
|
||||
{ name: "Transporte", type: "despesa", icon: "RiBusLine" },
|
||||
{ name: "Moradia", type: "despesa", icon: "RiHomeLine" },
|
||||
{ name: "Saúde", type: "despesa", icon: "RiStethoscopeLine" },
|
||||
{ name: "Educação", type: "despesa", icon: "RiBook2Line" },
|
||||
{ name: "Lazer", type: "despesa", icon: "RiGamepadLine" },
|
||||
{ name: "Compras", type: "despesa", icon: "RiShoppingBagLine" },
|
||||
{ name: "Assinaturas", type: "despesa", icon: "RiServiceLine" },
|
||||
{ name: "Pets", type: "despesa", icon: "RiBearSmileLine" },
|
||||
{ name: "Mercado", type: "despesa", icon: "RiShoppingBasketLine" },
|
||||
{ name: "Restaurantes", type: "despesa", icon: "RiRestaurantLine" },
|
||||
{ name: "Delivery", type: "despesa", icon: "RiMotorbikeLine" },
|
||||
{ name: "Energia e água", type: "despesa", icon: "RiFlashlightLine" },
|
||||
{ name: "Internet", type: "despesa", icon: "RiWifiLine" },
|
||||
{ name: "Vestuário", type: "despesa", icon: "RiTShirtLine" },
|
||||
{ name: "Viagem", type: "despesa", icon: "RiFlightTakeoffLine" },
|
||||
{ name: "Presentes", type: "despesa", icon: "RiGiftLine" },
|
||||
{ name: "Pagamentos", type: "despesa", icon: "RiBillLine" },
|
||||
{ name: "Outras despesas", type: "despesa", icon: "RiMore2Line" },
|
||||
|
||||
// Receitas
|
||||
{ name: "Salário", type: "receita", icon: "RiWallet3Line" },
|
||||
{ name: "Freelance", type: "receita", icon: "RiUserStarLine" },
|
||||
{ name: "Investimentos", type: "receita", icon: "RiStockLine" },
|
||||
{ name: "Vendas", type: "receita", icon: "RiShoppingCartLine" },
|
||||
{ name: "Prêmios", type: "receita", icon: "RiMedalLine" },
|
||||
{ name: "Reembolso", type: "receita", icon: "RiRefundLine" },
|
||||
{ name: "Aluguel recebido", type: "receita", icon: "RiBuilding2Line" },
|
||||
{ name: "Outras receitas", type: "receita", icon: "RiMore2Line" },
|
||||
{ name: "Saldo inicial", type: "receita", icon: "RiWallet2Line" },
|
||||
|
||||
// Categoria especial para transferências entre contas
|
||||
{
|
||||
name: "Transferência interna",
|
||||
type: "receita",
|
||||
icon: "RiArrowLeftRightLine",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Seeds default categories for a new user
|
||||
* @param userId - User ID to seed categories for
|
||||
*/
|
||||
export async function seedDefaultCategoriesForUser(userId: string | undefined) {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db.query.categorias.findFirst({
|
||||
columns: { id: true },
|
||||
where: eq(categorias.userId, userId),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEFAULT_CATEGORIES.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.insert(categorias).values(
|
||||
DEFAULT_CATEGORIES.map((category) => ({
|
||||
name: category.name,
|
||||
type: category.type,
|
||||
icon: category.icon,
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
173
src/shared/lib/categories/icons.ts
Normal file
173
src/shared/lib/categories/icons.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Category icon options and utilities
|
||||
*
|
||||
* Consolidated from:
|
||||
* - /lib/category-icons.ts
|
||||
*/
|
||||
|
||||
export type CategoryIconOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const CATEGORY_ICON_OPTIONS: CategoryIconOption[] = [
|
||||
// Finanças
|
||||
{ label: "Dinheiro", value: "RiMoneyDollarCircleLine" },
|
||||
{ label: "Carteira", value: "RiWallet3Line" },
|
||||
{ label: "Carteira 2", value: "RiWalletLine" },
|
||||
{ label: "Cartão", value: "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" },
|
||||
|
||||
// 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" },
|
||||
|
||||
// Moradia
|
||||
{ label: "Casa", value: "RiHomeLine" },
|
||||
{ label: "Prédio", value: "RiBuilding2Line" },
|
||||
{ label: "Apartamento", value: "RiBuildingLine" },
|
||||
{ label: "Ferramentas", value: "RiToolsLine" },
|
||||
{ label: "Lâmpada", value: "RiLightbulbLine" },
|
||||
{ label: "Energia", value: "RiFlashlightLine" },
|
||||
|
||||
// Saúde e bem-estar
|
||||
{ label: "Saúde", value: "RiStethoscopeLine" },
|
||||
{ label: "Hospital", value: "RiHospitalLine" },
|
||||
{ label: "Coração", value: "RiHeart2Line" },
|
||||
{ label: "Pulso", value: "RiHeartPulseLine" },
|
||||
{ label: "Mental", value: "RiMentalHealthLine" },
|
||||
{ label: "Farmácia", value: "RiFirstAidKitLine" },
|
||||
{ label: "Fitness", value: "RiRunLine" },
|
||||
|
||||
// Educação
|
||||
{ label: "Livro", value: "RiBook2Line" },
|
||||
{ label: "Graduação", value: "RiGraduationCapLine" },
|
||||
{ label: "Escola", value: "RiSchoolLine" },
|
||||
{ label: "Lápis", value: "RiPencilLine" },
|
||||
|
||||
// Trabalho
|
||||
{ label: "Maleta", value: "RiBriefcaseLine" },
|
||||
{ label: "Pasta", value: "RiBriefcase4Line" },
|
||||
{ label: "Escritório", value: "RiUserStarLine" },
|
||||
|
||||
// Lazer
|
||||
{ label: "Controle", value: "RiGamepadLine" },
|
||||
{ label: "Filme", value: "RiMovie2Line" },
|
||||
{ label: "Música", value: "RiMusic2Line" },
|
||||
{ label: "Microfone", value: "RiMicLine" },
|
||||
{ label: "Fone", value: "RiHeadphoneLine" },
|
||||
{ label: "Câmera", value: "RiCameraLine" },
|
||||
{ label: "Praia", value: "RiUmbrellaLine" },
|
||||
{ label: "Futebol", value: "RiFootballLine" },
|
||||
{ label: "Basquete", value: "RiBasketballLine" },
|
||||
|
||||
// Tecnologia
|
||||
{ label: "WiFi", value: "RiWifiLine" },
|
||||
{ label: "Celular", value: "RiSmartphoneLine" },
|
||||
{ label: "Computador", value: "RiComputerLine" },
|
||||
{ label: "Monitor", value: "RiMonitorLine" },
|
||||
{ label: "Teclado", value: "RiKeyboardLine" },
|
||||
{ label: "Mouse", value: "RiMouseLine" },
|
||||
{ label: "Fone Bluetooth", value: "RiBluetoothLine" },
|
||||
|
||||
// Pessoas
|
||||
{ label: "Usuário", value: "RiUserLine" },
|
||||
{ label: "Grupo", value: "RiGroupLine" },
|
||||
{ label: "Família", value: "RiParentLine" },
|
||||
{ label: "Bebê", value: "RiBabyCarriageLine" },
|
||||
|
||||
// Animais
|
||||
{ label: "Pet", value: "RiBearSmileLine" },
|
||||
|
||||
// Vestuário
|
||||
{ label: "Camiseta", value: "RiTShirtLine" },
|
||||
|
||||
// Documentos
|
||||
{ label: "Arquivo", value: "RiFileTextLine" },
|
||||
{ label: "Documento", value: "RiArticleLine" },
|
||||
{ label: "Balança", value: "RiScales2Line" },
|
||||
{ label: "Escudo", value: "RiShieldCheckLine" },
|
||||
|
||||
// Serviços
|
||||
{ label: "Serviço", value: "RiServiceLine" },
|
||||
{ label: "Alerta", value: "RiAlertLine" },
|
||||
{ label: "Troféu", value: "RiMedalLine" },
|
||||
|
||||
// Outros
|
||||
{ label: "Mais", value: "RiMore2Line" },
|
||||
{ label: "Estrela", value: "RiStarLine" },
|
||||
{ label: "Foguete", value: "RiRocketLine" },
|
||||
{ label: "Ampulheta", value: "RiHourglassLine" },
|
||||
{ label: "Calendário", value: "RiCalendarLine" },
|
||||
{ label: "Relógio", value: "RiTimeLine" },
|
||||
{ label: "Timer", value: "RiTimer2Line" },
|
||||
{ label: "Fogo", value: "RiFireLine" },
|
||||
{ label: "Gota", value: "RiDropLine" },
|
||||
{ label: "Sol", value: "RiSunLine" },
|
||||
{ label: "Lua", value: "RiMoonLine" },
|
||||
{ label: "Nuvem", value: "RiCloudLine" },
|
||||
{ label: "Raio", value: "RiFlashlightFill" },
|
||||
{ label: "Planta", value: "RiPlantLine" },
|
||||
{ label: "Árvore", value: "RiSeedlingLine" },
|
||||
{ label: "Globo", value: "RiGlobalLine" },
|
||||
{ label: "Localização", value: "RiMapPin2Line" },
|
||||
{ label: "Bússola", value: "RiCompassLine" },
|
||||
{ label: "Reciclagem", value: "RiRecycleLine" },
|
||||
{ label: "Cadeado", value: "RiLockLine" },
|
||||
{ label: "Chave", value: "RiKeyLine" },
|
||||
{ label: "Configurações", value: "RiSettings3Line" },
|
||||
{ label: "Link", value: "RiLinkLine" },
|
||||
{ label: "Anexo", value: "RiAttachmentLine" },
|
||||
{ label: "Download", value: "RiDownloadLine" },
|
||||
{ label: "Upload", value: "RiUploadLine" },
|
||||
{ label: "Nuvem Download", value: "RiCloudDownloadLine" },
|
||||
{ label: "Nuvem Upload", value: "RiCloudUploadLine" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets all available category icon options
|
||||
* @returns Array of icon options
|
||||
*/
|
||||
export function getCategoryIconOptions() {
|
||||
return CATEGORY_ICON_OPTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default icon for a category type
|
||||
* @returns Default icon value
|
||||
*/
|
||||
export function getDefaultIconForType() {
|
||||
return CATEGORY_ICON_OPTIONS[0]?.value ?? "";
|
||||
}
|
||||
39
src/shared/lib/db.ts
Normal file
39
src/shared/lib/db.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "@/db/schema";
|
||||
|
||||
const globalForDb = globalThis as unknown as {
|
||||
db?: NodePgDatabase<typeof schema>;
|
||||
pool?: Pool;
|
||||
};
|
||||
|
||||
let _db: NodePgDatabase<typeof schema> | undefined;
|
||||
let _pool: Pool | undefined;
|
||||
|
||||
function getDb() {
|
||||
if (_db) return _db;
|
||||
|
||||
const { DATABASE_URL } = process.env;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL env variable is not set");
|
||||
}
|
||||
|
||||
_pool = globalForDb.pool ?? new Pool({ connectionString: DATABASE_URL });
|
||||
_db = globalForDb.db ?? drizzle(_pool, { schema });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDb.pool = _pool;
|
||||
globalForDb.db = _db;
|
||||
}
|
||||
|
||||
return _db;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as NodePgDatabase<typeof schema>, {
|
||||
get(_, prop) {
|
||||
return Reflect.get(getDb(), prop);
|
||||
},
|
||||
});
|
||||
|
||||
export { schema };
|
||||
16
src/shared/lib/email/resend.ts
Normal file
16
src/shared/lib/email/resend.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { config } from "dotenv";
|
||||
|
||||
/**
|
||||
* Endereço "from" para envio de e-mails via Resend.
|
||||
* Lê RESEND_FROM_EMAIL do .env (valor deve estar entre aspas se tiver espaço:
|
||||
* Garante carregamento do .env no contexto da chamada (ex.: Server Actions).
|
||||
*/
|
||||
const FALLBACK_FROM = "OpenMonetis <noreply@resend.dev>";
|
||||
|
||||
export function getResendFromEmail(): string {
|
||||
// Garantir que .env foi carregado (não sobrescreve variáveis já definidas)
|
||||
config({ path: ".env" });
|
||||
const raw = process.env.RESEND_FROM_EMAIL;
|
||||
const value = typeof raw === "string" ? raw.trim() : "";
|
||||
return value.length > 0 ? value : FALLBACK_FROM;
|
||||
}
|
||||
68
src/shared/lib/installments/anticipation-helpers.ts
Normal file
68
src/shared/lib/installments/anticipation-helpers.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { EligibleInstallment } from "./anticipation-types";
|
||||
|
||||
/**
|
||||
* Formata o resumo de parcelas antecipadas
|
||||
* Exemplo: "Parcelas 1-3 de 12" ou "Parcela 5 de 12"
|
||||
*/
|
||||
export function formatAnticipatedInstallmentsRange(
|
||||
installments: EligibleInstallment[],
|
||||
): string {
|
||||
const numbers = installments
|
||||
.map((inst) => inst.currentInstallment)
|
||||
.filter((num): num is number => num !== null)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (numbers.length === 0) return "";
|
||||
if (numbers.length === 1) {
|
||||
const total = installments[0]?.installmentCount ?? 0;
|
||||
return `Parcela ${numbers[0]} de ${total}`;
|
||||
}
|
||||
|
||||
const first = numbers[0];
|
||||
const last = numbers[numbers.length - 1];
|
||||
const total = installments[0]?.installmentCount ?? 0;
|
||||
|
||||
// Se as parcelas são consecutivas
|
||||
const isConsecutive = numbers.every((num, i) => {
|
||||
if (i === 0) return true;
|
||||
return num === (numbers[i - 1] ?? 0) + 1;
|
||||
});
|
||||
|
||||
if (isConsecutive) {
|
||||
return `Parcelas ${first}-${last} de ${total}`;
|
||||
} else {
|
||||
return `${numbers.length} parcelas de ${total}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula quantas parcelas restam após uma antecipação
|
||||
*/
|
||||
export function calculateRemainingInstallments(
|
||||
totalInstallments: number,
|
||||
anticipatedCount: number,
|
||||
): number {
|
||||
return Math.max(0, totalInstallments - anticipatedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera descrição automática para o lançamento de antecipação
|
||||
*/
|
||||
export function generateAnticipationDescription(
|
||||
lancamentoName: string,
|
||||
installmentCount: number,
|
||||
): string {
|
||||
return `Antecipação de ${installmentCount} ${
|
||||
installmentCount === 1 ? "parcela" : "parcelas"
|
||||
} - ${lancamentoName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata nota automática para antecipação
|
||||
*/
|
||||
export function generateAnticipationNote(
|
||||
installments: EligibleInstallment[],
|
||||
): string {
|
||||
const range = formatAnticipatedInstallmentsRange(installments);
|
||||
return `Antecipação: ${range}`;
|
||||
}
|
||||
52
src/shared/lib/installments/anticipation-types.ts
Normal file
52
src/shared/lib/installments/anticipation-types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
AntecipacaoParcela,
|
||||
Categoria,
|
||||
Lancamento,
|
||||
Pagador,
|
||||
} from "@/db/schema";
|
||||
|
||||
/**
|
||||
* Parcela elegível para antecipação
|
||||
*/
|
||||
export type EligibleInstallment = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: string;
|
||||
period: string;
|
||||
purchaseDate: Date;
|
||||
dueDate: Date | null;
|
||||
currentInstallment: number | null;
|
||||
installmentCount: number | null;
|
||||
paymentMethod: string;
|
||||
categoriaId: string | null;
|
||||
pagadorId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Antecipação com dados completos
|
||||
*/
|
||||
export type InstallmentAnticipationWithRelations = AntecipacaoParcela & {
|
||||
lancamento: Lancamento;
|
||||
pagador: Pagador | null;
|
||||
categoria: Categoria | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input para criar antecipação
|
||||
*/
|
||||
export type CreateAnticipationInput = {
|
||||
seriesId: string;
|
||||
installmentIds: string[];
|
||||
anticipationPeriod: string;
|
||||
discount?: number;
|
||||
pagadorId?: string;
|
||||
categoriaId?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input para cancelar antecipação
|
||||
*/
|
||||
export type CancelAnticipationInput = {
|
||||
anticipationId: string;
|
||||
};
|
||||
70
src/shared/lib/installments/utils.ts
Normal file
70
src/shared/lib/installments/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { displayPeriod, periodToDate } from "@/shared/utils/period";
|
||||
|
||||
/**
|
||||
* Calcula a data da última parcela baseado no período da parcela atual
|
||||
* @param currentPeriod - Período da parcela atual no formato YYYY-MM (ex: "2025-11")
|
||||
* @param currentInstallment - Número da parcela atual
|
||||
* @param totalInstallments - Quantidade total de parcelas
|
||||
* @returns Data da última parcela
|
||||
*/
|
||||
export function calculateLastInstallmentDate(
|
||||
currentPeriod: string,
|
||||
currentInstallment: number,
|
||||
totalInstallments: number,
|
||||
): Date {
|
||||
let currentDate: Date;
|
||||
try {
|
||||
currentDate = periodToDate(currentPeriod);
|
||||
} catch {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
// Calcula quantas parcelas faltam (incluindo a atual)
|
||||
// Ex: parcela 2 de 6 -> restam 5 parcelas (2, 3, 4, 5, 6)
|
||||
const remainingInstallments = totalInstallments - currentInstallment + 1;
|
||||
|
||||
// Calcula quantos meses adicionar para chegar na última parcela
|
||||
// Ex: restam 5 parcelas -> adicionar 4 meses (parcela atual + 4 = 5 parcelas)
|
||||
const monthsToAdd = remainingInstallments - 1;
|
||||
|
||||
// Simplificando: monthsToAdd = totalInstallments - currentInstallment
|
||||
currentDate.setMonth(currentDate.getMonth() + monthsToAdd);
|
||||
|
||||
return currentDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata a data da última parcela no formato "Mês de Ano"
|
||||
* Exemplo: "Março de 2026"
|
||||
*/
|
||||
export function formatLastInstallmentDate(date: Date): string {
|
||||
return displayPeriod(
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata a data de compra no formato "dia, dd mmm"
|
||||
* Exemplo: "qua, 24 set"
|
||||
*/
|
||||
export function formatPurchaseDate(date: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat("pt-BR", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata o texto da parcela atual
|
||||
* Exemplo: "1 de 6"
|
||||
*/
|
||||
export function formatCurrentInstallment(
|
||||
current: number,
|
||||
total: number,
|
||||
): string {
|
||||
return `${current} de ${total}`;
|
||||
}
|
||||
32
src/shared/lib/invoices/index.ts
Normal file
32
src/shared/lib/invoices/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const INVOICE_PAYMENT_STATUS = {
|
||||
PENDING: "pendente",
|
||||
PAID: "pago",
|
||||
} as const;
|
||||
|
||||
export const INVOICE_STATUS_VALUES = Object.values(INVOICE_PAYMENT_STATUS);
|
||||
|
||||
export type InvoicePaymentStatus =
|
||||
(typeof INVOICE_PAYMENT_STATUS)[keyof typeof INVOICE_PAYMENT_STATUS];
|
||||
|
||||
export const INVOICE_STATUS_LABEL: Record<InvoicePaymentStatus, string> = {
|
||||
[INVOICE_PAYMENT_STATUS.PENDING]: "Em aberto",
|
||||
[INVOICE_PAYMENT_STATUS.PAID]: "Pago",
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_BADGE_VARIANT: Record<
|
||||
InvoicePaymentStatus,
|
||||
"default" | "secondary" | "success" | "info"
|
||||
> = {
|
||||
[INVOICE_PAYMENT_STATUS.PENDING]: "info",
|
||||
[INVOICE_PAYMENT_STATUS.PAID]: "success",
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_DESCRIPTION: Record<InvoicePaymentStatus, string> =
|
||||
{
|
||||
[INVOICE_PAYMENT_STATUS.PENDING]:
|
||||
"Esta fatura ainda não foi quitada. Você pode realizar o pagamento assim que revisar os lançamentos.",
|
||||
[INVOICE_PAYMENT_STATUS.PAID]:
|
||||
"Esta fatura está quitada. Caso tenha sido um engano, é possível desfazer o pagamento.",
|
||||
};
|
||||
|
||||
export const PERIOD_FORMAT_REGEX = /^\d{4}-\d{2}$/;
|
||||
71
src/shared/lib/logo/index.ts
Normal file
71
src/shared/lib/logo/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Logo utilities
|
||||
*
|
||||
* Consolidated from:
|
||||
* - /lib/logo.ts (utility functions)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes logo path to get just the filename
|
||||
* @param logo - Logo path or URL
|
||||
* @returns Filename only
|
||||
*/
|
||||
export const normalizeLogo = (logo?: string | null) =>
|
||||
logo?.split("/").filter(Boolean).pop() ?? "";
|
||||
|
||||
/**
|
||||
* Derives a display name from a logo filename
|
||||
* @param logo - Logo path or filename
|
||||
* @returns Formatted display name
|
||||
* @example
|
||||
* deriveNameFromLogo("my-company-logo.png") // "My Company Logo"
|
||||
*/
|
||||
export const deriveNameFromLogo = (logo?: string | null) => {
|
||||
if (!logo) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const fileName = normalizeLogo(logo);
|
||||
|
||||
if (!fileName) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const withoutExtension = fileName.replace(/\.[^/.]+$/, "");
|
||||
return withoutExtension
|
||||
.split(/[-_.\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
||||
|
||||
type ResolveLogoSrcOptions = {
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
export const resolveLogoSrc = (
|
||||
logo?: string | null,
|
||||
options?: ResolveLogoSrcOptions,
|
||||
) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (LOGO_SRC_PATTERN.test(logo)) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
if (logo.startsWith("/")) {
|
||||
return logo;
|
||||
}
|
||||
|
||||
const fileName = normalizeLogo(logo);
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePath = options?.basePath?.replace(/\/$/, "") || "/logos";
|
||||
return `${basePath}/${fileName}`;
|
||||
};
|
||||
23
src/shared/lib/logo/options.ts
Normal file
23
src/shared/lib/logo/options.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const LOGOS_DIRECTORY = path.join(process.cwd(), "public", "logos");
|
||||
const LOGO_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]);
|
||||
|
||||
/**
|
||||
* Loads available logo files from the public/logos directory
|
||||
* @returns Array of logo filenames sorted alphabetically
|
||||
*/
|
||||
export async function loadLogoOptions() {
|
||||
try {
|
||||
const files = await readdir(LOGOS_DIRECTORY, { withFileTypes: true });
|
||||
|
||||
return files
|
||||
.filter((file) => file.isFile())
|
||||
.map((file) => file.name)
|
||||
.filter((file) => LOGO_EXTENSIONS.has(path.extname(file).toLowerCase()))
|
||||
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
88
src/shared/lib/payers/access.ts
Normal file
88
src/shared/lib/payers/access.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
compartilhamentosPagador,
|
||||
pagadores,
|
||||
user as usersTable,
|
||||
} from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
|
||||
export type PagadorWithAccess = typeof pagadores.$inferSelect & {
|
||||
canEdit: boolean;
|
||||
sharedByName: string | null;
|
||||
sharedByEmail: string | null;
|
||||
shareId: string | null;
|
||||
};
|
||||
|
||||
export async function fetchPagadoresWithAccess(
|
||||
userId: string,
|
||||
): Promise<PagadorWithAccess[]> {
|
||||
const [owned, shared] = await Promise.all([
|
||||
db.query.pagadores.findMany({
|
||||
where: eq(pagadores.userId, userId),
|
||||
}),
|
||||
db
|
||||
.select({
|
||||
shareId: compartilhamentosPagador.id,
|
||||
pagador: pagadores,
|
||||
ownerName: usersTable.name,
|
||||
ownerEmail: usersTable.email,
|
||||
})
|
||||
.from(compartilhamentosPagador)
|
||||
.innerJoin(
|
||||
pagadores,
|
||||
eq(compartilhamentosPagador.pagadorId, pagadores.id),
|
||||
)
|
||||
.leftJoin(usersTable, eq(pagadores.userId, usersTable.id))
|
||||
.where(eq(compartilhamentosPagador.sharedWithUserId, userId)),
|
||||
]);
|
||||
|
||||
const ownedMapped: PagadorWithAccess[] = owned.map((item) => ({
|
||||
...item,
|
||||
canEdit: true,
|
||||
sharedByName: null,
|
||||
sharedByEmail: null,
|
||||
shareId: null,
|
||||
}));
|
||||
|
||||
const sharedMapped: PagadorWithAccess[] = shared.map((item) => ({
|
||||
...item.pagador,
|
||||
shareCode: null,
|
||||
canEdit: false,
|
||||
sharedByName: item.ownerName ?? null,
|
||||
sharedByEmail: item.ownerEmail ?? null,
|
||||
shareId: item.shareId,
|
||||
}));
|
||||
|
||||
return [...ownedMapped, ...sharedMapped];
|
||||
}
|
||||
|
||||
export async function getPagadorAccess(userId: string, pagadorId: string) {
|
||||
const pagador = await db.query.pagadores.findFirst({
|
||||
where: and(eq(pagadores.id, pagadorId)),
|
||||
});
|
||||
|
||||
if (!pagador) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pagador.userId === userId) {
|
||||
return {
|
||||
pagador,
|
||||
canEdit: true,
|
||||
share: null as typeof compartilhamentosPagador.$inferSelect | null,
|
||||
};
|
||||
}
|
||||
|
||||
const share = await db.query.compartilhamentosPagador.findFirst({
|
||||
where: and(
|
||||
eq(compartilhamentosPagador.pagadorId, pagadorId),
|
||||
eq(compartilhamentosPagador.sharedWithUserId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!share) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { pagador, canEdit: false, share };
|
||||
}
|
||||
7
src/shared/lib/payers/constants.ts
Normal file
7
src/shared/lib/payers/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const PAGADOR_STATUS_OPTIONS = ["Ativo", "Inativo"] as const;
|
||||
|
||||
export type PagadorStatus = (typeof PAGADOR_STATUS_OPTIONS)[number];
|
||||
|
||||
export const PAGADOR_ROLE_ADMIN = "admin";
|
||||
export const PAGADOR_ROLE_TERCEIRO = "terceiro";
|
||||
export const DEFAULT_PAGADOR_AVATAR = "default_icon.png";
|
||||
54
src/shared/lib/payers/defaults.ts
Normal file
54
src/shared/lib/payers/defaults.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { pagadores } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import {
|
||||
DEFAULT_PAGADOR_AVATAR,
|
||||
PAGADOR_ROLE_ADMIN,
|
||||
PAGADOR_STATUS_OPTIONS,
|
||||
} from "./constants";
|
||||
import { normalizeNameFromEmail } from "./utils";
|
||||
|
||||
const DEFAULT_STATUS = PAGADOR_STATUS_OPTIONS[0];
|
||||
|
||||
interface SeedUserLike {
|
||||
id?: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
export async function ensureDefaultPagadorForUser(user: SeedUserLike) {
|
||||
const userId = user.id;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasAnyPagador = await db.query.pagadores.findFirst({
|
||||
columns: { id: true, role: true },
|
||||
where: eq(pagadores.userId, userId),
|
||||
});
|
||||
|
||||
if (hasAnyPagador) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name =
|
||||
(user.name && user.name.trim().length > 0
|
||||
? user.name.trim()
|
||||
: normalizeNameFromEmail(user.email)) || "Pagador principal";
|
||||
|
||||
// Usa a imagem do Google se disponível, senão usa o avatar padrão
|
||||
const avatarUrl = user.image ?? DEFAULT_PAGADOR_AVATAR;
|
||||
|
||||
await db.insert(pagadores).values({
|
||||
name,
|
||||
email: user.email ?? null,
|
||||
status: DEFAULT_STATUS,
|
||||
role: PAGADOR_ROLE_ADMIN,
|
||||
avatarUrl,
|
||||
note: null,
|
||||
isAutoSend: false,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
379
src/shared/lib/payers/details.ts
Normal file
379
src/shared/lib/payers/details.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
eq,
|
||||
gte,
|
||||
ilike,
|
||||
isNull,
|
||||
lte,
|
||||
not,
|
||||
or,
|
||||
sql,
|
||||
sum,
|
||||
} from "drizzle-orm";
|
||||
import { cartoes, lancamentos } from "@/db/schema";
|
||||
import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/shared/lib/accounts/constants";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { toDateOnlyString } from "@/shared/utils/date";
|
||||
import { safeToNumber as toNumber } from "@/shared/utils/number";
|
||||
import {
|
||||
addMonthsToPeriod,
|
||||
buildPeriodRange,
|
||||
formatCompactPeriodLabel,
|
||||
} from "@/shared/utils/period";
|
||||
|
||||
const RECEITA = "Receita";
|
||||
const DESPESA = "Despesa";
|
||||
const PAYMENT_METHOD_CARD = "Cartão de crédito";
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
|
||||
export type PagadorMonthlyBreakdown = {
|
||||
totalExpenses: number;
|
||||
totalIncomes: number;
|
||||
paymentSplits: Record<"card" | "boleto" | "instant", number>;
|
||||
};
|
||||
|
||||
export type PagadorHistoryPoint = {
|
||||
period: string;
|
||||
label: string;
|
||||
receitas: number;
|
||||
despesas: number;
|
||||
};
|
||||
|
||||
export type PagadorCardUsageItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export type PagadorBoletoStats = {
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
pendingAmount: number;
|
||||
paidCount: number;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
export type PagadorBoletoItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
dueDate: string | null;
|
||||
boletoPaymentDate: string | null;
|
||||
isSettled: boolean;
|
||||
};
|
||||
|
||||
export type PagadorPaymentStatusData = {
|
||||
paidAmount: number;
|
||||
paidCount: number;
|
||||
pendingAmount: number;
|
||||
pendingCount: number;
|
||||
totalAmount: number;
|
||||
};
|
||||
|
||||
const excludeAutoInvoiceEntries = () =>
|
||||
or(
|
||||
isNull(lancamentos.note),
|
||||
not(ilike(lancamentos.note, `${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`)),
|
||||
);
|
||||
|
||||
type BaseFilters = {
|
||||
userId: string;
|
||||
pagadorId: string;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export async function fetchPagadorMonthlyBreakdown({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
}: BaseFilters): Promise<PagadorMonthlyBreakdown> {
|
||||
const rows = await db
|
||||
.select({
|
||||
paymentMethod: lancamentos.paymentMethod,
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.paymentMethod, lancamentos.transactionType);
|
||||
|
||||
const paymentSplits: PagadorMonthlyBreakdown["paymentSplits"] = {
|
||||
card: 0,
|
||||
boleto: 0,
|
||||
instant: 0,
|
||||
};
|
||||
let totalExpenses = 0;
|
||||
let totalIncomes = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const total = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === DESPESA) {
|
||||
totalExpenses += total;
|
||||
if (row.paymentMethod === PAYMENT_METHOD_CARD) {
|
||||
paymentSplits.card += total;
|
||||
} else if (row.paymentMethod === PAYMENT_METHOD_BOLETO) {
|
||||
paymentSplits.boleto += total;
|
||||
} else {
|
||||
paymentSplits.instant += total;
|
||||
}
|
||||
} else if (row.transactionType === RECEITA) {
|
||||
totalIncomes += total;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalExpenses,
|
||||
totalIncomes,
|
||||
paymentSplits,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPagadorHistory({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
months = 6,
|
||||
}: BaseFilters & { months?: number }): Promise<PagadorHistoryPoint[]> {
|
||||
const startPeriod = addMonthsToPeriod(period, -(Math.max(months, 1) - 1));
|
||||
const windowPeriods = buildPeriodRange(startPeriod, period);
|
||||
const start = windowPeriods[0];
|
||||
const end = windowPeriods[windowPeriods.length - 1];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
period: lancamentos.period,
|
||||
transactionType: lancamentos.transactionType,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
gte(lancamentos.period, start),
|
||||
lte(lancamentos.period, end),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.period, lancamentos.transactionType);
|
||||
|
||||
const totalsByPeriod = new Map<
|
||||
string,
|
||||
{ receitas: number; despesas: number }
|
||||
>();
|
||||
|
||||
for (const key of windowPeriods) {
|
||||
totalsByPeriod.set(key, { receitas: 0, despesas: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.period ?? undefined;
|
||||
if (!key || !totalsByPeriod.has(key)) continue;
|
||||
const bucket = totalsByPeriod.get(key);
|
||||
if (!bucket) continue;
|
||||
const total = Math.abs(toNumber(row.totalAmount));
|
||||
if (row.transactionType === DESPESA) {
|
||||
bucket.despesas += total;
|
||||
} else if (row.transactionType === RECEITA) {
|
||||
bucket.receitas += total;
|
||||
}
|
||||
}
|
||||
|
||||
return windowPeriods.map((key) => ({
|
||||
period: key,
|
||||
label: formatCompactPeriodLabel(key),
|
||||
receitas: totalsByPeriod.get(key)?.receitas ?? 0,
|
||||
despesas: totalsByPeriod.get(key)?.despesas ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchPagadorCardUsage({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
}: BaseFilters): Promise<PagadorCardUsageItem[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
cartaoId: lancamentos.cartaoId,
|
||||
cardName: cartoes.name,
|
||||
cardLogo: cartoes.logo,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_CARD),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.cartaoId, cartoes.name, cartoes.logo);
|
||||
|
||||
const items: PagadorCardUsageItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.cartaoId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: row.cartaoId,
|
||||
name: row.cardName ?? "Cartão",
|
||||
logo: row.cardLogo ?? null,
|
||||
amount: Math.abs(toNumber(row.totalAmount)),
|
||||
});
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
export async function fetchPagadorBoletoStats({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
}: BaseFilters): Promise<PagadorBoletoStats> {
|
||||
const rows = await db
|
||||
.select({
|
||||
isSettled: lancamentos.isSettled,
|
||||
totalAmount: sum(lancamentos.amount).as("total"),
|
||||
totalCount: sql<number>`count(${lancamentos.id})`.as("count"),
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.groupBy(lancamentos.isSettled);
|
||||
|
||||
let paidAmount = 0;
|
||||
let pendingAmount = 0;
|
||||
let paidCount = 0;
|
||||
let pendingCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const total = Math.abs(toNumber(row.totalAmount));
|
||||
const count = toNumber(row.totalCount);
|
||||
if (row.isSettled) {
|
||||
paidAmount += total;
|
||||
paidCount += count;
|
||||
} else {
|
||||
pendingAmount += total;
|
||||
pendingCount += count;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalAmount: paidAmount + pendingAmount,
|
||||
paidAmount,
|
||||
pendingAmount,
|
||||
paidCount,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPagadorBoletoItems({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
}: BaseFilters): Promise<PagadorBoletoItem[]> {
|
||||
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)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(lancamentos.dueDate));
|
||||
|
||||
const items: PagadorBoletoItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
items.push({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
amount: Math.abs(toNumber(row.amount)),
|
||||
dueDate: toDateOnlyString(row.dueDate),
|
||||
boletoPaymentDate: toDateOnlyString(row.boletoPaymentDate),
|
||||
isSettled: Boolean(row.isSettled),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function fetchPagadorPaymentStatus({
|
||||
userId,
|
||||
pagadorId,
|
||||
period,
|
||||
}: BaseFilters): Promise<PagadorPaymentStatusData> {
|
||||
const rows = await db
|
||||
.select({
|
||||
paidAmount: sql<string>`coalesce(sum(case when ${lancamentos.isSettled} = true then abs(${lancamentos.amount}) else 0 end), 0)`,
|
||||
paidCount: sql<number>`sum(case when ${lancamentos.isSettled} = true then 1 else 0 end)`,
|
||||
pendingAmount: sql<string>`coalesce(sum(case when (${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null) then abs(${lancamentos.amount}) else 0 end), 0)`,
|
||||
pendingCount: sql<number>`sum(case when (${lancamentos.isSettled} = false or ${lancamentos.isSettled} is null) then 1 else 0 end)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.pagadorId, pagadorId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(lancamentos.transactionType, DESPESA),
|
||||
excludeAutoInvoiceEntries(),
|
||||
),
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return {
|
||||
paidAmount: 0,
|
||||
paidCount: 0,
|
||||
pendingAmount: 0,
|
||||
pendingCount: 0,
|
||||
totalAmount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const paidAmount = toNumber(row.paidAmount);
|
||||
const paidCount = toNumber(row.paidCount);
|
||||
const pendingAmount = toNumber(row.pendingAmount);
|
||||
const pendingCount = toNumber(row.pendingCount);
|
||||
|
||||
return {
|
||||
paidAmount,
|
||||
paidCount,
|
||||
pendingAmount,
|
||||
pendingCount,
|
||||
totalAmount: paidAmount + pendingAmount,
|
||||
};
|
||||
}
|
||||
25
src/shared/lib/payers/get-admin-id.ts
Normal file
25
src/shared/lib/payers/get-admin-id.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import { pagadores } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/shared/lib/payers/constants";
|
||||
|
||||
/**
|
||||
* Returns the admin pagador ID for a user (cached per request via React.cache).
|
||||
* Eliminates the need for JOIN with pagadores in ~20 dashboard queries.
|
||||
*/
|
||||
export const getAdminPagadorId = cache(
|
||||
async (userId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ id: pagadores.id })
|
||||
.from(pagadores)
|
||||
.where(
|
||||
and(
|
||||
eq(pagadores.userId, userId),
|
||||
eq(pagadores.role, PAGADOR_ROLE_ADMIN),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
},
|
||||
);
|
||||
240
src/shared/lib/payers/notifications.ts
Normal file
240
src/shared/lib/payers/notifications.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
import { pagadores } from "@/db/schema";
|
||||
import { db } from "@/shared/lib/db";
|
||||
import { getResendFromEmail } from "@/shared/lib/email/resend";
|
||||
import { formatCurrency } from "@/shared/utils/currency";
|
||||
import { formatDateTime } from "@/shared/utils/date";
|
||||
|
||||
type ActionType = "created" | "deleted";
|
||||
|
||||
export type NotificationEntry = {
|
||||
pagadorId: string;
|
||||
name: string | null;
|
||||
amount: number;
|
||||
transactionType: string | null;
|
||||
paymentMethod: string | null;
|
||||
condition: string | null;
|
||||
purchaseDate: Date | null;
|
||||
period: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type PagadorNotificationRequest = {
|
||||
userLabel: string;
|
||||
action: ActionType;
|
||||
entriesByPagador: Map<string, NotificationEntry[]>;
|
||||
};
|
||||
|
||||
type PagadorNotificationRecipient = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
isAutoSend: boolean | null;
|
||||
};
|
||||
|
||||
const formatDate = (value: Date | null) => {
|
||||
return (
|
||||
formatDateTime(value, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}) ?? "—"
|
||||
);
|
||||
};
|
||||
|
||||
const buildHtmlBody = ({
|
||||
userLabel,
|
||||
action,
|
||||
entries,
|
||||
}: {
|
||||
userLabel: string;
|
||||
action: ActionType;
|
||||
entries: NotificationEntry[];
|
||||
}) => {
|
||||
const actionLabel =
|
||||
action === "created" ? "Novo lançamento registrado" : "Lançamento removido";
|
||||
const actionDescription =
|
||||
action === "created"
|
||||
? "Um novo lançamento foi registrado em seu nome."
|
||||
: "Um lançamento anteriormente registrado em seu nome foi removido.";
|
||||
|
||||
const rows = entries
|
||||
.map((entry) => {
|
||||
const label =
|
||||
entry.transactionType === "Despesa"
|
||||
? `${formatCurrency(Math.abs(entry.amount))} (Despesa)`
|
||||
: `${formatCurrency(Math.abs(entry.amount))} (Receita)`;
|
||||
return `<tr>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${formatDate(
|
||||
entry.purchaseDate,
|
||||
)}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.name ?? "Sem descrição"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.paymentMethod ?? "—"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;">${
|
||||
entry.condition ?? "—"
|
||||
}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e2e8f0;text-align:right;">${label}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div style="font-family:'Inter',Arial,sans-serif;color:#0f172a;line-height:1.5;">
|
||||
<h2 style="margin:0 0 8px 0;font-size:20px;">${actionLabel}</h2>
|
||||
<p style="margin:0 0 16px 0;color:#475569;">${actionDescription}</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;margin-bottom:16px;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Data</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Descrição</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Pagamento</th>
|
||||
<th style="text-align:left;padding:8px;border-bottom:1px solid #e2e8f0;">Condição</th>
|
||||
<th style="text-align:right;padding:8px;border-bottom:1px solid #e2e8f0;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;font-size:12px;color:#94a3b8;">
|
||||
Enviado automaticamente por ${userLabel} via OpenMonetis.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export async function sendPagadorAutoEmails({
|
||||
userLabel,
|
||||
action,
|
||||
entriesByPagador,
|
||||
}: PagadorNotificationRequest) {
|
||||
"use server";
|
||||
|
||||
if (entriesByPagador.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resendApiKey = process.env.RESEND_API_KEY;
|
||||
const resendFrom = getResendFromEmail();
|
||||
|
||||
if (!resendApiKey) {
|
||||
console.warn(
|
||||
"RESEND_API_KEY não configurada. Envio automático de lançamentos ignorado.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pagadorIds = Array.from(entriesByPagador.keys());
|
||||
if (pagadorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pagadorRows = (await db.query.pagadores.findMany({
|
||||
where: inArray(pagadores.id, pagadorIds),
|
||||
})) as PagadorNotificationRecipient[];
|
||||
|
||||
if (pagadorRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resend = new Resend(resendApiKey);
|
||||
const subjectPrefix =
|
||||
action === "created" ? "Novo lançamento" : "Lançamento removido";
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
pagadorRows.map(async (pagador: PagadorNotificationRecipient) => {
|
||||
if (!pagador.email || !pagador.isAutoSend) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = entriesByPagador.get(pagador.id);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = buildHtmlBody({
|
||||
userLabel,
|
||||
action,
|
||||
entries,
|
||||
});
|
||||
|
||||
await resend.emails.send({
|
||||
from: resendFrom,
|
||||
to: pagador.email,
|
||||
subject: `${subjectPrefix} - ${pagador.name}`,
|
||||
html,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Log any failed email sends
|
||||
results.forEach((result: PromiseSettledResult<void>, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
const pagador = pagadorRows[index];
|
||||
console.error(
|
||||
`Failed to send email notification to ${pagador?.name} (${pagador?.email}):`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type RawNotificationRecord = {
|
||||
pagadorId: string | null;
|
||||
name: string | null;
|
||||
amount: string | number | null;
|
||||
transactionType: string | null;
|
||||
paymentMethod: string | null;
|
||||
condition: string | null;
|
||||
purchaseDate: Date | string | null;
|
||||
period: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export const buildEntriesByPagador = (
|
||||
records: RawNotificationRecord[],
|
||||
): Map<string, NotificationEntry[]> => {
|
||||
const map = new Map<string, NotificationEntry[]>();
|
||||
|
||||
records.forEach((record) => {
|
||||
if (!record.pagadorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amount =
|
||||
typeof record.amount === "number"
|
||||
? record.amount
|
||||
: Number(record.amount ?? 0);
|
||||
const purchaseDate =
|
||||
record.purchaseDate instanceof Date
|
||||
? record.purchaseDate
|
||||
: record.purchaseDate
|
||||
? new Date(record.purchaseDate)
|
||||
: null;
|
||||
|
||||
const entry: NotificationEntry = {
|
||||
pagadorId: record.pagadorId,
|
||||
name: record.name ?? null,
|
||||
amount,
|
||||
transactionType: record.transactionType ?? null,
|
||||
paymentMethod: record.paymentMethod ?? null,
|
||||
condition: record.condition ?? null,
|
||||
purchaseDate,
|
||||
period: record.period ?? null,
|
||||
note: record.note ?? null,
|
||||
};
|
||||
|
||||
const list = map.get(record.pagadorId) ?? [];
|
||||
list.push(entry);
|
||||
map.set(record.pagadorId, list);
|
||||
});
|
||||
|
||||
return map;
|
||||
};
|
||||
70
src/shared/lib/payers/utils.ts
Normal file
70
src/shared/lib/payers/utils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { DEFAULT_PAGADOR_AVATAR } from "./constants";
|
||||
|
||||
/**
|
||||
* Normaliza o caminho do avatar extraindo apenas o nome do arquivo.
|
||||
* Remove qualquer caminho anterior e retorna null se não houver avatar.
|
||||
* Preserva URLs completas (http/https/data).
|
||||
*/
|
||||
export const normalizeAvatarPath = (
|
||||
avatar: string | null | undefined,
|
||||
): string | null => {
|
||||
if (!avatar) return null;
|
||||
|
||||
// Preservar URLs completas (Google, etc)
|
||||
if (
|
||||
avatar.startsWith("http://") ||
|
||||
avatar.startsWith("https://") ||
|
||||
avatar.startsWith("data:")
|
||||
) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
const file = avatar.split("/").filter(Boolean).pop();
|
||||
return file ?? avatar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retorna o caminho completo para o avatar, com fallback para o avatar padrão.
|
||||
* Se o avatar for uma URL completa (http/https/data), retorna diretamente.
|
||||
*/
|
||||
export const getAvatarSrc = (avatar: string | null | undefined): string => {
|
||||
if (!avatar) {
|
||||
return `/avatars/${DEFAULT_PAGADOR_AVATAR}`;
|
||||
}
|
||||
|
||||
// Se for uma URL completa (Google, etc), retorna diretamente
|
||||
if (
|
||||
avatar.startsWith("http://") ||
|
||||
avatar.startsWith("https://") ||
|
||||
avatar.startsWith("data:")
|
||||
) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
// Se for um caminho local, normaliza e adiciona o prefixo
|
||||
const normalized = normalizeAvatarPath(avatar);
|
||||
return `/avatars/${normalized ?? DEFAULT_PAGADOR_AVATAR}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normaliza nome a partir de email
|
||||
*/
|
||||
export const normalizeNameFromEmail = (
|
||||
email: string | null | undefined,
|
||||
): string => {
|
||||
if (!email) {
|
||||
return "Novo pagador";
|
||||
}
|
||||
const [local] = email.split("@");
|
||||
if (!local) {
|
||||
return "Novo pagador";
|
||||
}
|
||||
return local
|
||||
.split(".")
|
||||
.map((segment) =>
|
||||
segment.length > 0
|
||||
? segment[0]?.toUpperCase().concat(segment.slice(1))
|
||||
: segment,
|
||||
)
|
||||
.join(" ");
|
||||
};
|
||||
38
src/shared/lib/preferences/fonts.ts
Normal file
38
src/shared/lib/preferences/fonts.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
import {
|
||||
DEFAULT_FONT_KEY,
|
||||
type FontKey,
|
||||
normalizeFontKey,
|
||||
} from "@/public/fonts/font_index";
|
||||
import { db, schema } from "@/shared/lib/db";
|
||||
|
||||
export type FontPreferences = {
|
||||
systemFont: FontKey;
|
||||
moneyFont: FontKey;
|
||||
};
|
||||
|
||||
const DEFAULT_FONT_PREFS: FontPreferences = {
|
||||
systemFont: DEFAULT_FONT_KEY,
|
||||
moneyFont: DEFAULT_FONT_KEY,
|
||||
};
|
||||
|
||||
export const fetchUserFontPreferences = cache(
|
||||
async (userId: string): Promise<FontPreferences> => {
|
||||
const result = await db
|
||||
.select({
|
||||
systemFont: schema.preferenciasUsuario.systemFont,
|
||||
moneyFont: schema.preferenciasUsuario.moneyFont,
|
||||
})
|
||||
.from(schema.preferenciasUsuario)
|
||||
.where(eq(schema.preferenciasUsuario.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!result[0]) return DEFAULT_FONT_PREFS;
|
||||
|
||||
return {
|
||||
systemFont: normalizeFontKey(result[0].systemFont),
|
||||
moneyFont: normalizeFontKey(result[0].moneyFont),
|
||||
};
|
||||
},
|
||||
);
|
||||
59
src/shared/lib/schemas/common.ts
Normal file
59
src/shared/lib/schemas/common.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Common Zod schemas for reuse across the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* UUID schema with custom error message
|
||||
*/
|
||||
export const uuidSchema = (entityName: string = "ID") =>
|
||||
z
|
||||
.string({ message: `${entityName} inválido.` })
|
||||
.uuid(`${entityName} inválido.`);
|
||||
|
||||
/**
|
||||
* Optional/nullable decimal string schema
|
||||
*/
|
||||
export const optionalDecimalSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((value) =>
|
||||
value && value.length > 0 ? value.replace(",", ".") : null,
|
||||
)
|
||||
.refine(
|
||||
(value) => value === null || !Number.isNaN(Number.parseFloat(value)),
|
||||
"Informe um valor numérico válido.",
|
||||
)
|
||||
.transform((value) => (value === null ? null : Number.parseFloat(value)));
|
||||
|
||||
/**
|
||||
* Day of month schema (1-31)
|
||||
*/
|
||||
export const dayOfMonthSchema = z
|
||||
.string({ message: "Informe o dia." })
|
||||
.trim()
|
||||
.min(1, "Informe o dia.")
|
||||
.refine((value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return !Number.isNaN(parsed) && parsed >= 1 && parsed <= 31;
|
||||
}, "Informe um dia entre 1 e 31.");
|
||||
|
||||
/**
|
||||
* Period schema (YYYY-MM format)
|
||||
*/
|
||||
export const periodSchema = z
|
||||
.string({ message: "Informe o período." })
|
||||
.trim()
|
||||
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Período inválido.");
|
||||
|
||||
/**
|
||||
* 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));
|
||||
16
src/shared/lib/schemas/inbox.ts
Normal file
16
src/shared/lib/schemas/inbox.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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(),
|
||||
clientId: z.string().optional(), // ID local do app para rastreamento
|
||||
});
|
||||
|
||||
export const inboxBatchSchema = z.object({
|
||||
items: z.array(inboxItemSchema).min(1).max(50),
|
||||
});
|
||||
3
src/shared/lib/schemas/index.ts
Normal file
3
src/shared/lib/schemas/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./common";
|
||||
export * from "./inbox";
|
||||
export * from "./insights";
|
||||
67
src/shared/lib/schemas/insights.ts
Normal file
67
src/shared/lib/schemas/insights.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Categorias de insights
|
||||
*/
|
||||
export const INSIGHT_CATEGORIES = {
|
||||
behaviors: {
|
||||
id: "behaviors",
|
||||
title: "Comportamentos Observados",
|
||||
icon: "RiEyeLine",
|
||||
color: "blue",
|
||||
},
|
||||
triggers: {
|
||||
id: "triggers",
|
||||
title: "Gatilhos de Consumo",
|
||||
icon: "RiFlashlightLine",
|
||||
color: "amber",
|
||||
},
|
||||
recommendations: {
|
||||
id: "recommendations",
|
||||
title: "Recomendações Práticas",
|
||||
icon: "RiLightbulbLine",
|
||||
color: "green",
|
||||
},
|
||||
improvements: {
|
||||
id: "improvements",
|
||||
title: "Melhorias Sugeridas",
|
||||
icon: "RiRocketLine",
|
||||
color: "purple",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type InsightCategoryId = keyof typeof INSIGHT_CATEGORIES;
|
||||
|
||||
/**
|
||||
* Schema para item individual de insight
|
||||
*/
|
||||
export const InsightItemSchema = z.object({
|
||||
text: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema para categoria de insights
|
||||
*/
|
||||
export const InsightCategorySchema = z.object({
|
||||
category: z.enum([
|
||||
"behaviors",
|
||||
"triggers",
|
||||
"recommendations",
|
||||
"improvements",
|
||||
]),
|
||||
items: z.array(InsightItemSchema).min(1).max(6),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for complete insights response from AI
|
||||
*/
|
||||
export const InsightsResponseSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/), // YYYY-MM
|
||||
generatedAt: z.string(), // ISO datetime
|
||||
categories: z.array(InsightCategorySchema).length(4),
|
||||
});
|
||||
|
||||
/**
|
||||
* TypeScript types derived from schemas
|
||||
*/
|
||||
export type InsightsResponse = z.infer<typeof InsightsResponseSchema>;
|
||||
13
src/shared/lib/schemas/recurring-series.ts
Normal file
13
src/shared/lib/schemas/recurring-series.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { uuidSchema } from "./common";
|
||||
|
||||
/**
|
||||
* Schema for pause/resume/cancel recurring series actions
|
||||
*/
|
||||
export const recurringSeriesActionSchema = z.object({
|
||||
seriesId: uuidSchema("Série recorrente"),
|
||||
});
|
||||
|
||||
export type RecurringSeriesActionInput = z.infer<
|
||||
typeof recurringSeriesActionSchema
|
||||
>;
|
||||
5
src/shared/lib/transfers/constants.ts
Normal file
5
src/shared/lib/transfers/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TRANSFER_CATEGORY_NAME = "Transferência interna";
|
||||
export const TRANSFER_ESTABLISHMENT_SAIDA = "Saída - Transf. entre contas";
|
||||
export const TRANSFER_ESTABLISHMENT_ENTRADA = "Entrada - Transf. entre contas";
|
||||
export const TRANSFER_PAYMENT_METHOD = "Pix";
|
||||
export const TRANSFER_CONDITION = "À vista";
|
||||
13
src/shared/lib/types/actions.ts
Normal file
13
src/shared/lib/types/actions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Standard action result type
|
||||
*/
|
||||
export type ActionResult<TData = void> =
|
||||
| { success: true; message: string; data?: TData }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Error result helper
|
||||
*/
|
||||
export function errorResult(error: string): ActionResult {
|
||||
return { success: false, error };
|
||||
}
|
||||
62
src/shared/lib/types/calendar.ts
Normal file
62
src/shared/lib/types/calendar.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
LancamentoItem,
|
||||
SelectOption,
|
||||
} from "@/features/transactions/components/types";
|
||||
|
||||
export type CalendarEvent =
|
||||
| {
|
||||
id: string;
|
||||
type: "lancamento";
|
||||
date: string;
|
||||
lancamento: LancamentoItem;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "boleto";
|
||||
date: string;
|
||||
lancamento: LancamentoItem;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "cartao";
|
||||
date: string;
|
||||
card: {
|
||||
id: string;
|
||||
name: string;
|
||||
dueDay: string;
|
||||
closingDay: string;
|
||||
brand: string | null;
|
||||
status: string;
|
||||
logo: string | null;
|
||||
totalDue: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type CalendarPeriod = {
|
||||
period: string;
|
||||
monthName: string;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type CalendarDay = {
|
||||
date: string;
|
||||
label: string;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
events: CalendarEvent[];
|
||||
};
|
||||
|
||||
export type CalendarFormOptions = {
|
||||
pagadorOptions: SelectOption[];
|
||||
splitPagadorOptions: SelectOption[];
|
||||
defaultPagadorId: string | null;
|
||||
contaOptions: SelectOption[];
|
||||
cartaoOptions: SelectOption[];
|
||||
categoriaOptions: SelectOption[];
|
||||
estabelecimentos: string[];
|
||||
};
|
||||
|
||||
export type CalendarData = {
|
||||
events: CalendarEvent[];
|
||||
formOptions: CalendarFormOptions;
|
||||
};
|
||||
3
src/shared/lib/types/index.ts
Normal file
3
src/shared/lib/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./actions";
|
||||
export * from "./calendar";
|
||||
export * from "./reports";
|
||||
52
src/shared/lib/types/reports.ts
Normal file
52
src/shared/lib/types/reports.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Types for Category Report feature
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation result for date range
|
||||
*/
|
||||
export type DateRangeValidation = {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user