diff --git a/.vscode/settings.json b/.vscode/settings.json index 1371262..693984d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,20 @@ { - "files.exclude": { - "**/.git": true, - "**/.svn": true, - "**/.hg": true, - "**/.DS_Store": true, - "**/Thumbs.db": true, - "**/node_modules": true, - "node_modules": true, - "**/.vscode": true, - ".vscode": true, - "**/.next": true, - ".next": true - }, - "explorerExclude.backup": {}, - "editor.defaultFormatter": "esbenp.prettier-vscode" + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/node_modules": true, + "node_modules": true, + "**/.vscode": true, + ".vscode": true, + "**/.next": true, + ".next": true + }, + "explorerExclude.backup": {}, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + } } diff --git a/README.md b/README.md index 00561a8..38a6f36 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment - Next.js 16.1 com App Router - Turbopack (fast refresh) - TypeScript 5.9 (strict mode) -- ESLint 9 +- Biome (linting + formatting) - React 19.2 (com Compiler) - Server Actions - Parallel data fetching @@ -322,7 +322,7 @@ O projeto é open source, seus dados ficam no seu controle (pode rodar localment - **Containerization:** Docker + Docker Compose - **Package Manager:** pnpm - **Build Tool:** Turbopack -- **Linting:** ESLint 9.39.2 +- **Linting & Formatting:** Biome 2.x - **Analytics:** Vercel Analytics + Speed Insights --- @@ -991,7 +991,7 @@ opensheets/ ├── tailwind.config.ts # Configuração Tailwind CSS ├── postcss.config.mjs # PostCSS config ├── components.json # shadcn/ui config -├── eslint.config.mjs # ESLint config +├── biome.json # Biome config (linting + formatting) ├── tsconfig.json # TypeScript config ├── package.json # Dependências e scripts ├── .env.example # Template de variáveis de ambiente diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 46ec175..a2235ca 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,11 +1,11 @@ import { LoginForm } from "@/components/auth/login-form"; export default function LoginPage() { - return ( -
-
- -
-
- ); + return ( +
+
+ +
+
+ ); } diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 17bd733..dd81e6e 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1,11 +1,11 @@ import { SignupForm } from "@/components/auth/signup-form"; export default function Page() { - return ( -
-
- -
-
- ); + return ( +
+
+ +
+
+ ); } diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts index e1bd098..2225ed3 100644 --- a/app/(dashboard)/ajustes/actions.ts +++ b/app/(dashboard)/ajustes/actions.ts @@ -1,562 +1,567 @@ "use server"; +import { apiTokens, pagadores } from "@/db/schema"; import { auth } from "@/lib/auth/config"; import { db, schema } from "@/lib/db"; -import { apiTokens, pagadores } from "@/db/schema"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { eq, and, ne, isNull } from "drizzle-orm"; -import { headers } from "next/headers"; +import { and, eq, isNull, ne } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { createHash, randomBytes } from "node:crypto"; import { z } from "zod"; -import { createHash, randomBytes } from "crypto"; type ActionResponse = { - success: boolean; - message?: string; - error?: string; - data?: T; + success: boolean; + message?: string; + error?: string; + data?: T; }; // Schema de validação const updateNameSchema = z.object({ - firstName: z.string().min(1, "Primeiro nome é obrigatório"), - lastName: z.string().min(1, "Sobrenome é obrigatório"), + firstName: z.string().min(1, "Primeiro nome é obrigatório"), + lastName: z.string().min(1, "Sobrenome é obrigatório"), }); const updatePasswordSchema = z - .object({ - currentPassword: z.string().min(1, "Senha atual é obrigatória"), - newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"), - confirmPassword: z.string(), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: "As senhas não coincidem", - path: ["confirmPassword"], - }); + .object({ + currentPassword: z.string().min(1, "Senha atual é obrigatória"), + newPassword: z.string().min(6, "A senha deve ter no mínimo 6 caracteres"), + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "As senhas não coincidem", + path: ["confirmPassword"], + }); const updateEmailSchema = z - .object({ - password: z.string().optional(), // Opcional para usuários Google OAuth - newEmail: z.string().email("E-mail inválido"), - confirmEmail: z.string().email("E-mail inválido"), - }) - .refine((data) => data.newEmail === data.confirmEmail, { - message: "Os e-mails não coincidem", - path: ["confirmEmail"], - }); + .object({ + password: z.string().optional(), // Opcional para usuários Google OAuth + newEmail: z.string().email("E-mail inválido"), + confirmEmail: z.string().email("E-mail inválido"), + }) + .refine((data) => data.newEmail === data.confirmEmail, { + message: "Os e-mails não coincidem", + path: ["confirmEmail"], + }); const deleteAccountSchema = z.object({ - confirmation: z.literal("DELETAR", { - errorMap: () => ({ message: 'Você deve digitar "DELETAR" para confirmar' }), - }), + confirmation: z.literal("DELETAR", { + errorMap: () => ({ message: 'Você deve digitar "DELETAR" para confirmar' }), + }), }); const updatePreferencesSchema = z.object({ - disableMagnetlines: z.boolean(), + disableMagnetlines: z.boolean(), }); // Actions export async function updateNameAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = updateNameSchema.parse(data); - const fullName = `${validated.firstName} ${validated.lastName}`; + const validated = updateNameSchema.parse(data); + const fullName = `${validated.firstName} ${validated.lastName}`; - // Atualizar nome do usuário - await db - .update(schema.user) - .set({ name: fullName }) - .where(eq(schema.user.id, session.user.id)); + // Atualizar nome do usuário + await db + .update(schema.user) + .set({ name: fullName }) + .where(eq(schema.user.id, session.user.id)); - // Sincronizar nome com o pagador admin - await db - .update(pagadores) - .set({ name: fullName }) - .where( - and( - eq(pagadores.userId, session.user.id), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ); + // Sincronizar nome com o pagador admin + await db + .update(pagadores) + .set({ name: fullName }) + .where( + and( + eq(pagadores.userId, session.user.id), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ); - // Revalidar o layout do dashboard para atualizar a sidebar - revalidatePath("/", "layout"); - revalidatePath("/pagadores"); + // Revalidar o layout do dashboard para atualizar a sidebar + revalidatePath("/", "layout"); + revalidatePath("/pagadores"); - return { - success: true, - message: "Nome atualizado com sucesso", - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: "Nome atualizado com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao atualizar nome:", error); - return { - success: false, - error: "Erro ao atualizar nome. Tente novamente.", - }; - } + console.error("Erro ao atualizar nome:", error); + return { + success: false, + error: "Erro ao atualizar nome. Tente novamente.", + }; + } } export async function updatePasswordAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id || !session?.user?.email) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id || !session?.user?.email) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = updatePasswordSchema.parse(data); + const validated = updatePasswordSchema.parse(data); - // Verificar se o usuário tem conta com provedor Google - const userAccount = await db.query.account.findFirst({ - where: and( - eq(schema.account.userId, session.user.id), - eq(schema.account.providerId, "google") - ), - }); + // Verificar se o usuário tem conta com provedor Google + const userAccount = await db.query.account.findFirst({ + where: and( + eq(schema.account.userId, session.user.id), + eq(schema.account.providerId, "google"), + ), + }); - if (userAccount) { - return { - success: false, - error: "Não é possível alterar senha para contas autenticadas via Google", - }; - } + if (userAccount) { + return { + success: false, + error: + "Não é possível alterar senha para contas autenticadas via Google", + }; + } - // Usar a API do Better Auth para atualizar a senha - try { - await auth.api.changePassword({ - body: { - newPassword: validated.newPassword, - currentPassword: validated.currentPassword, - }, - headers: await headers(), - }); + // Usar a API do Better Auth para atualizar a senha + try { + await auth.api.changePassword({ + body: { + newPassword: validated.newPassword, + currentPassword: validated.currentPassword, + }, + headers: await headers(), + }); - return { - success: true, - message: "Senha atualizada com sucesso", - }; - } catch (authError: any) { - console.error("Erro na API do Better Auth:", authError); + return { + success: true, + message: "Senha atualizada com sucesso", + }; + } catch (authError: any) { + console.error("Erro na API do Better Auth:", authError); - // Verificar se o erro é de senha incorreta - if (authError?.message?.includes("password") || authError?.message?.includes("incorrect")) { - return { - success: false, - error: "Senha atual incorreta", - }; - } + // Verificar se o erro é de senha incorreta + if ( + authError?.message?.includes("password") || + authError?.message?.includes("incorrect") + ) { + return { + success: false, + error: "Senha atual incorreta", + }; + } - return { - success: false, - error: "Erro ao atualizar senha. Verifique se a senha atual está correta.", - }; - } - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: false, + error: + "Erro ao atualizar senha. Verifique se a senha atual está correta.", + }; + } + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao atualizar senha:", error); - return { - success: false, - error: "Erro ao atualizar senha. Tente novamente.", - }; - } + console.error("Erro ao atualizar senha:", error); + return { + success: false, + error: "Erro ao atualizar senha. Tente novamente.", + }; + } } export async function updateEmailAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id || !session?.user?.email) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id || !session?.user?.email) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = updateEmailSchema.parse(data); + const validated = updateEmailSchema.parse(data); - // Verificar se o usuário tem conta com provedor Google - const userAccount = await db.query.account.findFirst({ - where: and( - eq(schema.account.userId, session.user.id), - eq(schema.account.providerId, "google") - ), - }); + // Verificar se o usuário tem conta com provedor Google + const userAccount = await db.query.account.findFirst({ + where: and( + eq(schema.account.userId, session.user.id), + eq(schema.account.providerId, "google"), + ), + }); - const isGoogleAuth = !!userAccount; + const isGoogleAuth = !!userAccount; - // Se não for Google OAuth, validar senha - if (!isGoogleAuth) { - if (!validated.password) { - return { - success: false, - error: "Senha é obrigatória para confirmar a alteração", - }; - } + // Se não for Google OAuth, validar senha + if (!isGoogleAuth) { + if (!validated.password) { + return { + success: false, + error: "Senha é obrigatória para confirmar a alteração", + }; + } - // Validar senha tentando fazer changePassword para a mesma senha - // Se falhar, a senha atual está incorreta - try { - await auth.api.changePassword({ - body: { - newPassword: validated.password, - currentPassword: validated.password, - }, - headers: await headers(), - }); - } catch (authError: any) { - // Se der erro é porque a senha está incorreta - console.error("Erro ao validar senha:", authError); - return { - success: false, - error: "Senha incorreta", - }; - } - } + // Validar senha tentando fazer changePassword para a mesma senha + // Se falhar, a senha atual está incorreta + try { + await auth.api.changePassword({ + body: { + newPassword: validated.password, + currentPassword: validated.password, + }, + headers: await headers(), + }); + } catch (authError: any) { + // Se der erro é porque a senha está incorreta + console.error("Erro ao validar senha:", authError); + return { + success: false, + error: "Senha incorreta", + }; + } + } - // Verificar se o e-mail já está em uso por outro usuário - const existingUser = await db.query.user.findFirst({ - where: and( - eq(schema.user.email, validated.newEmail), - ne(schema.user.id, session.user.id) - ), - }); + // Verificar se o e-mail já está em uso por outro usuário + const existingUser = await db.query.user.findFirst({ + where: and( + eq(schema.user.email, validated.newEmail), + ne(schema.user.id, session.user.id), + ), + }); - if (existingUser) { - return { - success: false, - error: "Este e-mail já está em uso", - }; - } + if (existingUser) { + return { + success: false, + error: "Este e-mail já está em uso", + }; + } - // Verificar se o novo e-mail é diferente do atual - if (validated.newEmail.toLowerCase() === session.user.email.toLowerCase()) { - return { - success: false, - error: "O novo e-mail deve ser diferente do atual", - }; - } + // Verificar se o novo e-mail é diferente do atual + if (validated.newEmail.toLowerCase() === session.user.email.toLowerCase()) { + return { + success: false, + error: "O novo e-mail deve ser diferente do atual", + }; + } - // Atualizar e-mail - await db - .update(schema.user) - .set({ - email: validated.newEmail, - emailVerified: false, // Marcar como não verificado - }) - .where(eq(schema.user.id, session.user.id)); + // Atualizar e-mail + await db + .update(schema.user) + .set({ + email: validated.newEmail, + emailVerified: false, // Marcar como não verificado + }) + .where(eq(schema.user.id, session.user.id)); - // Revalidar o layout do dashboard para atualizar a sidebar - revalidatePath("/", "layout"); + // Revalidar o layout do dashboard para atualizar a sidebar + revalidatePath("/", "layout"); - return { - success: true, - message: - "E-mail atualizado com sucesso. Por favor, verifique seu novo e-mail.", - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: + "E-mail atualizado com sucesso. Por favor, verifique seu novo e-mail.", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao atualizar e-mail:", error); - return { - success: false, - error: "Erro ao atualizar e-mail. Tente novamente.", - }; - } + console.error("Erro ao atualizar e-mail:", error); + return { + success: false, + error: "Erro ao atualizar e-mail. Tente novamente.", + }; + } } export async function deleteAccountAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } - // Validar confirmação - deleteAccountSchema.parse(data); + // Validar confirmação + deleteAccountSchema.parse(data); - // Deletar todos os dados do usuário em cascade - // O schema deve ter as relações configuradas com onDelete: cascade - await db.delete(schema.user).where(eq(schema.user.id, session.user.id)); + // Deletar todos os dados do usuário em cascade + // O schema deve ter as relações configuradas com onDelete: cascade + await db.delete(schema.user).where(eq(schema.user.id, session.user.id)); - return { - success: true, - message: "Conta deletada com sucesso", - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: "Conta deletada com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao deletar conta:", error); - return { - success: false, - error: "Erro ao deletar conta. Tente novamente.", - }; - } + console.error("Erro ao deletar conta:", error); + return { + success: false, + error: "Erro ao deletar conta. Tente novamente.", + }; + } } export async function updatePreferencesAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = updatePreferencesSchema.parse(data); + const validated = updatePreferencesSchema.parse(data); - // Check if preferences exist, if not create them - const existingResult = await db - .select() - .from(schema.userPreferences) - .where(eq(schema.userPreferences.userId, session.user.id)) - .limit(1); + // Check if preferences exist, if not create them + const existingResult = await db + .select() + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, session.user.id)) + .limit(1); - const existing = existingResult[0] || null; + const existing = existingResult[0] || null; - if (existing) { - // Update existing preferences - await db - .update(schema.userPreferences) - .set({ - disableMagnetlines: validated.disableMagnetlines, - updatedAt: new Date(), - }) - .where(eq(schema.userPreferences.userId, session.user.id)); - } else { - // Create new preferences - await db.insert(schema.userPreferences).values({ - userId: session.user.id, - disableMagnetlines: validated.disableMagnetlines, - }); - } + if (existing) { + // Update existing preferences + await db + .update(schema.userPreferences) + .set({ + disableMagnetlines: validated.disableMagnetlines, + updatedAt: new Date(), + }) + .where(eq(schema.userPreferences.userId, session.user.id)); + } else { + // Create new preferences + await db.insert(schema.userPreferences).values({ + userId: session.user.id, + disableMagnetlines: validated.disableMagnetlines, + }); + } - // Revalidar o layout do dashboard - revalidatePath("/", "layout"); + // Revalidar o layout do dashboard + revalidatePath("/", "layout"); - return { - success: true, - message: "Preferências atualizadas com sucesso", - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: "Preferências atualizadas com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao atualizar preferências:", error); - return { - success: false, - error: "Erro ao atualizar preferências. Tente novamente.", - }; - } + console.error("Erro ao atualizar preferências:", error); + return { + success: false, + error: "Erro ao atualizar preferências. Tente novamente.", + }; + } } // API Token Actions const createApiTokenSchema = z.object({ - name: z.string().min(1, "Nome do dispositivo é obrigatório").max(100), + name: z.string().min(1, "Nome do dispositivo é obrigatório").max(100), }); const revokeApiTokenSchema = z.object({ - tokenId: z.string().uuid("ID do token inválido"), + tokenId: z.string().uuid("ID do token inválido"), }); function generateSecureToken(): string { - const prefix = "os"; - const randomPart = randomBytes(32).toString("base64url"); - return `${prefix}_${randomPart}`; + const prefix = "os"; + const randomPart = randomBytes(32).toString("base64url"); + return `${prefix}_${randomPart}`; } function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); + return createHash("sha256").update(token).digest("hex"); } export async function createApiTokenAction( - data: z.infer + data: z.infer, ): Promise> { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = createApiTokenSchema.parse(data); + const validated = createApiTokenSchema.parse(data); - // Generate token - const token = generateSecureToken(); - const tokenHash = hashToken(token); - const tokenPrefix = token.substring(0, 10); + // Generate token + const token = generateSecureToken(); + const tokenHash = hashToken(token); + const tokenPrefix = token.substring(0, 10); - // Save to database - const [newToken] = await db - .insert(apiTokens) - .values({ - userId: session.user.id, - name: validated.name, - tokenHash, - tokenPrefix, - expiresAt: null, // No expiration for now - }) - .returning({ id: apiTokens.id }); + // Save to database + const [newToken] = await db + .insert(apiTokens) + .values({ + userId: session.user.id, + name: validated.name, + tokenHash, + tokenPrefix, + expiresAt: null, // No expiration for now + }) + .returning({ id: apiTokens.id }); - revalidatePath("/ajustes"); + revalidatePath("/ajustes"); - return { - success: true, - message: "Token criado com sucesso", - data: { - token, - tokenId: newToken.id, - }, - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: "Token criado com sucesso", + data: { + token, + tokenId: newToken.id, + }, + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao criar token:", error); - return { - success: false, - error: "Erro ao criar token. Tente novamente.", - }; - } + console.error("Erro ao criar token:", error); + return { + success: false, + error: "Erro ao criar token. Tente novamente.", + }; + } } export async function revokeApiTokenAction( - data: z.infer + data: z.infer, ): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user?.id) { - return { - success: false, - error: "Não autenticado", - }; - } + if (!session?.user?.id) { + return { + success: false, + error: "Não autenticado", + }; + } - const validated = revokeApiTokenSchema.parse(data); + const validated = revokeApiTokenSchema.parse(data); - // Find token and verify ownership - const [existingToken] = await db - .select() - .from(apiTokens) - .where( - and( - eq(apiTokens.id, validated.tokenId), - eq(apiTokens.userId, session.user.id), - isNull(apiTokens.revokedAt) - ) - ) - .limit(1); + // Find token and verify ownership + const [existingToken] = await db + .select() + .from(apiTokens) + .where( + and( + eq(apiTokens.id, validated.tokenId), + eq(apiTokens.userId, session.user.id), + isNull(apiTokens.revokedAt), + ), + ) + .limit(1); - if (!existingToken) { - return { - success: false, - error: "Token não encontrado", - }; - } + if (!existingToken) { + return { + success: false, + error: "Token não encontrado", + }; + } - // Revoke token - await db - .update(apiTokens) - .set({ - revokedAt: new Date(), - }) - .where(eq(apiTokens.id, validated.tokenId)); + // Revoke token + await db + .update(apiTokens) + .set({ + revokedAt: new Date(), + }) + .where(eq(apiTokens.id, validated.tokenId)); - revalidatePath("/ajustes"); + revalidatePath("/ajustes"); - return { - success: true, - message: "Token revogado com sucesso", - }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message || "Dados inválidos", - }; - } + return { + success: true, + message: "Token revogado com sucesso", + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message || "Dados inválidos", + }; + } - console.error("Erro ao revogar token:", error); - return { - success: false, - error: "Erro ao revogar token. Tente novamente.", - }; - } + console.error("Erro ao revogar token:", error); + return { + success: false, + error: "Erro ao revogar token. Tente novamente.", + }; + } } diff --git a/app/(dashboard)/ajustes/data.ts b/app/(dashboard)/ajustes/data.ts new file mode 100644 index 0000000..90d2a42 --- /dev/null +++ b/app/(dashboard)/ajustes/data.ts @@ -0,0 +1,70 @@ +import { desc, eq } from "drizzle-orm"; +import { apiTokens } from "@/db/schema"; +import { db, schema } from "@/lib/db"; + +export interface UserPreferences { + disableMagnetlines: boolean; +} + +export interface ApiToken { + id: string; + name: string; + tokenPrefix: string; + lastUsedAt: Date | null; + lastUsedIp: string | null; + createdAt: Date; + expiresAt: Date | null; + revokedAt: Date | null; +} + +export async function fetchAuthProvider(userId: string): Promise { + const userAccount = await db.query.account.findFirst({ + where: eq(schema.account.userId, userId), + }); + return userAccount?.providerId || "credential"; +} + +export async function fetchUserPreferences( + userId: string, +): Promise { + const result = await db + .select({ + disableMagnetlines: schema.userPreferences.disableMagnetlines, + }) + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, userId)) + .limit(1); + + return result[0] || null; +} + +export async function fetchApiTokens(userId: string): Promise { + return db + .select({ + id: apiTokens.id, + name: apiTokens.name, + tokenPrefix: apiTokens.tokenPrefix, + lastUsedAt: apiTokens.lastUsedAt, + lastUsedIp: apiTokens.lastUsedIp, + createdAt: apiTokens.createdAt, + expiresAt: apiTokens.expiresAt, + revokedAt: apiTokens.revokedAt, + }) + .from(apiTokens) + .where(eq(apiTokens.userId, userId)) + .orderBy(desc(apiTokens.createdAt)); +} + +export async function fetchAjustesPageData(userId: string) { + const [authProvider, userPreferences, userApiTokens] = await Promise.all([ + fetchAuthProvider(userId), + fetchUserPreferences(userId), + fetchApiTokens(userId), + ]); + + return { + authProvider, + userPreferences, + userApiTokens, + }; +} diff --git a/app/(dashboard)/ajustes/layout.tsx b/app/(dashboard)/ajustes/layout.tsx index aeb813c..d3ef178 100644 --- a/app/(dashboard)/ajustes/layout.tsx +++ b/app/(dashboard)/ajustes/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiSettingsLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Ajustes | Opensheets", + title: "Ajustes | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Ajustes" - subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." - /> - {children} -
- ); + return ( +
+ } + title="Ajustes" + subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx index f15b2d2..010fd63 100644 --- a/app/(dashboard)/ajustes/page.tsx +++ b/app/(dashboard)/ajustes/page.tsx @@ -1,180 +1,148 @@ -import { ApiTokensForm } from "@/components/ajustes/api-tokens-form"; -import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; -import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; -import { UpdateNameForm } from "@/components/ajustes/update-name-form"; -import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; -import { PreferencesForm } from "@/components/ajustes/preferences-form"; -import { Card } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { auth } from "@/lib/auth/config"; -import { db, schema } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +import { ApiTokensForm } from "@/components/ajustes/api-tokens-form"; +import { DeleteAccountForm } from "@/components/ajustes/delete-account-form"; +import { PreferencesForm } from "@/components/ajustes/preferences-form"; +import { UpdateEmailForm } from "@/components/ajustes/update-email-form"; +import { UpdateNameForm } from "@/components/ajustes/update-name-form"; +import { UpdatePasswordForm } from "@/components/ajustes/update-password-form"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { auth } from "@/lib/auth/config"; + +import { fetchAjustesPageData } from "./data"; + export default async function Page() { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.user) { - redirect("/"); - } + if (!session?.user) { + redirect("/"); + } - const userName = session.user.name || ""; - const userEmail = session.user.email || ""; + const userName = session.user.name || ""; + const userEmail = session.user.email || ""; - // Detectar método de autenticação (Google OAuth vs E-mail/Senha) - const userAccount = await db.query.account.findFirst({ - where: eq(schema.account.userId, session.user.id), - }); + const { authProvider, userPreferences, userApiTokens } = + await fetchAjustesPageData(session.user.id); - // Buscar preferências do usuário - const userPreferencesResult = await db - .select({ - disableMagnetlines: schema.userPreferences.disableMagnetlines, - }) - .from(schema.userPreferences) - .where(eq(schema.userPreferences.userId, session.user.id)) - .limit(1); + return ( +
+ + + Preferências + Dispositivos + Alterar nome + Alterar senha + Alterar e-mail + + Deletar conta + + - const userPreferences = userPreferencesResult[0] || null; + + +
+
+

Preferências

+

+ Personalize sua experiência no Opensheets ajustando as + configurações de acordo com suas necessidades. +

+
+ +
+
+
- // Se o providerId for "google", o usuário usa Google OAuth - const authProvider = userAccount?.providerId || "credential"; + + +
+
+

OpenSheets Companion

+

+ Conecte o app Android OpenSheets Companion para capturar + automaticamente notificações de transações financeiras e + enviá-las para sua caixa de entrada. +

+
+ +
+
+
- // Buscar tokens de API do usuário - const userApiTokens = await db - .select({ - id: apiTokens.id, - name: apiTokens.name, - tokenPrefix: apiTokens.tokenPrefix, - lastUsedAt: apiTokens.lastUsedAt, - lastUsedIp: apiTokens.lastUsedIp, - createdAt: apiTokens.createdAt, - expiresAt: apiTokens.expiresAt, - revokedAt: apiTokens.revokedAt, - }) - .from(apiTokens) - .where(eq(apiTokens.userId, session.user.id)) - .orderBy(desc(apiTokens.createdAt)); + + +
+
+

Alterar nome

+

+ Atualize como seu nome aparece no Opensheets. Esse nome pode + ser exibido em diferentes seções do app e em comunicações. +

+
+ +
+
+
- return ( -
- - - Preferências - Dispositivos - Alterar nome - Alterar senha - Alterar e-mail - - Deletar conta - - + + +
+
+

Alterar senha

+

+ Defina uma nova senha para sua conta. Guarde-a em local + seguro. +

+
+ +
+
+
- - -
-
-

Preferências

-

- Personalize sua experiência no Opensheets ajustando as - configurações de acordo com suas necessidades. -

-
- -
-
-
+ + +
+
+

Alterar e-mail

+

+ Atualize o e-mail associado à sua conta. Você precisará + confirmar os links enviados para o novo e também para o e-mail + atual (quando aplicável) para concluir a alteração. +

+
+ +
+
+
- - -
-
-

OpenSheets Companion

-

- Conecte o app Android OpenSheets Companion para capturar - automaticamente notificações de transações financeiras e - enviá-las para sua caixa de entrada. -

-
- -
-
-
- - - -
-
-

Alterar nome

-

- Atualize como seu nome aparece no Opensheets. Esse nome pode - ser exibido em diferentes seções do app e em comunicações. -

-
- -
-
-
- - - -
-
-

Alterar senha

-

- Defina uma nova senha para sua conta. Guarde-a em local - seguro. -

-
- -
-
-
- - - -
-
-

Alterar e-mail

-

- Atualize o e-mail associado à sua conta. Você precisará - confirmar os links enviados para o novo e também para o e-mail - atual (quando aplicável) para concluir a alteração. -

-
- -
-
-
- - - -
-
-

- Deletar conta -

-

- Ao prosseguir, sua conta e todos os dados associados serão - excluídos de forma irreversível. -

-
- -
-
-
-
-
- ); + + +
+
+

+ Deletar conta +

+

+ Ao prosseguir, sua conta e todos os dados associados serão + excluídos de forma irreversível. +

+
+ +
+
+
+
+
+ ); } diff --git a/app/(dashboard)/anotacoes/actions.ts b/app/(dashboard)/anotacoes/actions.ts index 72512b4..2ab92d5 100644 --- a/app/(dashboard)/anotacoes/actions.ts +++ b/app/(dashboard)/anotacoes/actions.ts @@ -1,59 +1,64 @@ "use server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { anotacoes } from "@/db/schema"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import type { ActionResult } from "@/lib/actions/types"; -import { uuidSchema } from "@/lib/schemas/common"; -import { db } from "@/lib/db"; import { getUser } from "@/lib/auth/server"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; +import { db } from "@/lib/db"; +import { uuidSchema } from "@/lib/schemas/common"; const taskSchema = z.object({ - id: z.string(), - text: z.string().min(1, "O texto da tarefa não pode estar vazio."), - completed: z.boolean(), + id: z.string(), + text: z.string().min(1, "O texto da tarefa não pode estar vazio."), + completed: z.boolean(), }); -const noteBaseSchema = z.object({ - title: z - .string({ message: "Informe o título da anotação." }) - .trim() - .min(1, "Informe o título da anotação.") - .max(30, "O título deve ter no máximo 30 caracteres."), - description: z - .string({ message: "Informe o conteúdo da anotação." }) - .trim() - .max(350, "O conteúdo deve ter no máximo 350 caracteres.") - .optional() - .default(""), - type: z.enum(["nota", "tarefa"], { - message: "O tipo deve ser 'nota' ou 'tarefa'.", - }), - tasks: z.array(taskSchema).optional().default([]), -}).refine( - (data) => { - // Se for nota, a descrição é obrigatória - if (data.type === "nota") { - return data.description.trim().length > 0; - } - // Se for tarefa, deve ter pelo menos uma tarefa - if (data.type === "tarefa") { - return data.tasks && data.tasks.length > 0; - } - return true; - }, - { - message: "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.", - } -); +const noteBaseSchema = z + .object({ + title: z + .string({ message: "Informe o título da anotação." }) + .trim() + .min(1, "Informe o título da anotação.") + .max(30, "O título deve ter no máximo 30 caracteres."), + description: z + .string({ message: "Informe o conteúdo da anotação." }) + .trim() + .max(350, "O conteúdo deve ter no máximo 350 caracteres.") + .optional() + .default(""), + type: z.enum(["nota", "tarefa"], { + message: "O tipo deve ser 'nota' ou 'tarefa'.", + }), + tasks: z.array(taskSchema).optional().default([]), + }) + .refine( + (data) => { + // Se for nota, a descrição é obrigatória + if (data.type === "nota") { + return data.description.trim().length > 0; + } + // Se for tarefa, deve ter pelo menos uma tarefa + if (data.type === "tarefa") { + return data.tasks && data.tasks.length > 0; + } + return true; + }, + { + message: + "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.", + }, + ); const createNoteSchema = noteBaseSchema; -const updateNoteSchema = noteBaseSchema.and(z.object({ - id: uuidSchema("Anotação"), -})); +const updateNoteSchema = noteBaseSchema.and( + z.object({ + id: uuidSchema("Anotação"), + }), +); const deleteNoteSchema = z.object({ - id: uuidSchema("Anotação"), + id: uuidSchema("Anotação"), }); type NoteCreateInput = z.infer; @@ -61,126 +66,130 @@ type NoteUpdateInput = z.infer; type NoteDeleteInput = z.infer; export async function createNoteAction( - input: NoteCreateInput + input: NoteCreateInput, ): Promise { - try { - const user = await getUser(); - const data = createNoteSchema.parse(input); + try { + const user = await getUser(); + const data = createNoteSchema.parse(input); - await db.insert(anotacoes).values({ - title: data.title, - description: data.description, - type: data.type, - tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null, - userId: user.id, - }); + await db.insert(anotacoes).values({ + title: data.title, + description: data.description, + type: data.type, + tasks: + data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null, + userId: user.id, + }); - revalidateForEntity("anotacoes"); + revalidateForEntity("anotacoes"); - return { success: true, message: "Anotação criada com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Anotação criada com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updateNoteAction( - input: NoteUpdateInput + input: NoteUpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateNoteSchema.parse(input); + try { + const user = await getUser(); + const data = updateNoteSchema.parse(input); - const [updated] = await db - .update(anotacoes) - .set({ - title: data.title, - description: data.description, - type: data.type, - tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null, - }) - .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) - .returning({ id: anotacoes.id }); + const [updated] = await db + .update(anotacoes) + .set({ + title: data.title, + description: data.description, + type: data.type, + tasks: + data.tasks && data.tasks.length > 0 + ? JSON.stringify(data.tasks) + : null, + }) + .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) + .returning({ id: anotacoes.id }); - if (!updated) { - return { - success: false, - error: "Anotação não encontrada.", - }; - } + if (!updated) { + return { + success: false, + error: "Anotação não encontrada.", + }; + } - revalidateForEntity("anotacoes"); + revalidateForEntity("anotacoes"); - return { success: true, message: "Anotação atualizada com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Anotação atualizada com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deleteNoteAction( - input: NoteDeleteInput + input: NoteDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteNoteSchema.parse(input); + try { + const user = await getUser(); + const data = deleteNoteSchema.parse(input); - const [deleted] = await db - .delete(anotacoes) - .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) - .returning({ id: anotacoes.id }); + const [deleted] = await db + .delete(anotacoes) + .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) + .returning({ id: anotacoes.id }); - if (!deleted) { - return { - success: false, - error: "Anotação não encontrada.", - }; - } + if (!deleted) { + return { + success: false, + error: "Anotação não encontrada.", + }; + } - revalidateForEntity("anotacoes"); + revalidateForEntity("anotacoes"); - return { success: true, message: "Anotação removida com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Anotação removida com sucesso." }; + } catch (error) { + return handleActionError(error); + } } const arquivarNoteSchema = z.object({ - id: uuidSchema("Anotação"), - arquivada: z.boolean(), + id: uuidSchema("Anotação"), + arquivada: z.boolean(), }); type NoteArquivarInput = z.infer; export async function arquivarAnotacaoAction( - input: NoteArquivarInput + input: NoteArquivarInput, ): Promise { - try { - const user = await getUser(); - const data = arquivarNoteSchema.parse(input); + try { + const user = await getUser(); + const data = arquivarNoteSchema.parse(input); - const [updated] = await db - .update(anotacoes) - .set({ - arquivada: data.arquivada, - }) - .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) - .returning({ id: anotacoes.id }); + const [updated] = await db + .update(anotacoes) + .set({ + arquivada: data.arquivada, + }) + .where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id))) + .returning({ id: anotacoes.id }); - if (!updated) { - return { - success: false, - error: "Anotação não encontrada.", - }; - } + if (!updated) { + return { + success: false, + error: "Anotação não encontrada.", + }; + } - revalidateForEntity("anotacoes"); + revalidateForEntity("anotacoes"); - return { - success: true, - message: data.arquivada - ? "Anotação arquivada com sucesso." - : "Anotação desarquivada com sucesso." - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: data.arquivada + ? "Anotação arquivada com sucesso." + : "Anotação desarquivada com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/anotacoes/arquivadas/page.tsx b/app/(dashboard)/anotacoes/arquivadas/page.tsx index 035ba5a..a1ce516 100644 --- a/app/(dashboard)/anotacoes/arquivadas/page.tsx +++ b/app/(dashboard)/anotacoes/arquivadas/page.tsx @@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server"; import { fetchArquivadasForUser } from "../data"; export default async function ArquivadasPage() { - const userId = await getUserId(); - const notes = await fetchArquivadasForUser(userId); + const userId = await getUserId(); + const notes = await fetchArquivadasForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/anotacoes/data.ts b/app/(dashboard)/anotacoes/data.ts index a4f50cb..91b3f14 100644 --- a/app/(dashboard)/anotacoes/data.ts +++ b/app/(dashboard)/anotacoes/data.ts @@ -1,81 +1,89 @@ -import { anotacoes, type Anotacao } from "@/db/schema"; -import { db } from "@/lib/db"; import { and, eq } from "drizzle-orm"; +import { type Anotacao, anotacoes } from "@/db/schema"; +import { db } from "@/lib/db"; export type Task = { - id: string; - text: string; - completed: boolean; + id: string; + text: string; + completed: boolean; }; export type NoteData = { - id: string; - title: string; - description: string; - type: "nota" | "tarefa"; - tasks?: Task[]; - arquivada: boolean; - createdAt: string; + id: string; + title: string; + description: string; + type: "nota" | "tarefa"; + tasks?: Task[]; + arquivada: boolean; + createdAt: string; }; export async function fetchNotesForUser(userId: string): Promise { - const noteRows = await db.query.anotacoes.findMany({ - where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), - orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)], - }); + const noteRows = await db.query.anotacoes.findMany({ + where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, false)), + orderBy: ( + note: typeof anotacoes.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(note.createdAt)], + }); - return noteRows.map((note: Anotacao) => { - let tasks: Task[] | undefined; + return noteRows.map((note: Anotacao) => { + let tasks: Task[] | undefined; - // Parse tasks if they exist - if (note.tasks) { - try { - tasks = JSON.parse(note.tasks); - } catch (error) { - console.error("Failed to parse tasks for note", note.id, error); - tasks = undefined; - } - } + // Parse tasks if they exist + if (note.tasks) { + try { + tasks = JSON.parse(note.tasks); + } catch (error) { + console.error("Failed to parse tasks for note", note.id, error); + tasks = undefined; + } + } - return { - id: note.id, - title: (note.title ?? "").trim(), - description: (note.description ?? "").trim(), - type: (note.type ?? "nota") as "nota" | "tarefa", - tasks, - arquivada: note.arquivada, - createdAt: note.createdAt.toISOString(), - }; - }); + return { + id: note.id, + title: (note.title ?? "").trim(), + description: (note.description ?? "").trim(), + type: (note.type ?? "nota") as "nota" | "tarefa", + tasks, + arquivada: note.arquivada, + createdAt: note.createdAt.toISOString(), + }; + }); } -export async function fetchArquivadasForUser(userId: string): Promise { - const noteRows = await db.query.anotacoes.findMany({ - where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)), - orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)], - }); +export async function fetchArquivadasForUser( + userId: string, +): Promise { + const noteRows = await db.query.anotacoes.findMany({ + where: and(eq(anotacoes.userId, userId), eq(anotacoes.arquivada, true)), + orderBy: ( + note: typeof anotacoes.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(note.createdAt)], + }); - return noteRows.map((note: Anotacao) => { - let tasks: Task[] | undefined; + return noteRows.map((note: Anotacao) => { + let tasks: Task[] | undefined; - // Parse tasks if they exist - if (note.tasks) { - try { - tasks = JSON.parse(note.tasks); - } catch (error) { - console.error("Failed to parse tasks for note", note.id, error); - tasks = undefined; - } - } + // Parse tasks if they exist + if (note.tasks) { + try { + tasks = JSON.parse(note.tasks); + } catch (error) { + console.error("Failed to parse tasks for note", note.id, error); + tasks = undefined; + } + } - return { - id: note.id, - title: (note.title ?? "").trim(), - description: (note.description ?? "").trim(), - type: (note.type ?? "nota") as "nota" | "tarefa", - tasks, - arquivada: note.arquivada, - createdAt: note.createdAt.toISOString(), - }; - }); + return { + id: note.id, + title: (note.title ?? "").trim(), + description: (note.description ?? "").trim(), + type: (note.type ?? "nota") as "nota" | "tarefa", + tasks, + arquivada: note.arquivada, + createdAt: note.createdAt.toISOString(), + }; + }); } diff --git a/app/(dashboard)/anotacoes/layout.tsx b/app/(dashboard)/anotacoes/layout.tsx index 5808b61..4024723 100644 --- a/app/(dashboard)/anotacoes/layout.tsx +++ b/app/(dashboard)/anotacoes/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiTodoLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Anotações | Opensheets", + title: "Anotações | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Notas" - subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas." - /> - {children} -
- ); + return ( +
+ } + title="Notas" + subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/anotacoes/loading.tsx b/app/(dashboard)/anotacoes/loading.tsx index 7ee612f..5e1d1e5 100644 --- a/app/(dashboard)/anotacoes/loading.tsx +++ b/app/(dashboard)/anotacoes/loading.tsx @@ -5,47 +5,44 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: Header com botão + Grid de cards de notas */ export default function AnotacoesLoading() { - return ( -
-
- {/* Header */} -
- - -
+ return ( +
+
+ {/* Header */} +
+ + +
- {/* Grid de cards de notas */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- {/* Título */} - + {/* Grid de cards de notas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Título */} + - {/* Conteúdo (3-4 linhas) */} -
- - - - {i % 2 === 0 && ( - - )} -
+ {/* Conteúdo (3-4 linhas) */} +
+ + + + {i % 2 === 0 && ( + + )} +
- {/* Footer com data e ações */} -
- -
- - -
-
-
- ))} -
-
-
- ); + {/* Footer com data e ações */} +
+ +
+ + +
+
+
+ ))} + + +
+ ); } diff --git a/app/(dashboard)/anotacoes/page.tsx b/app/(dashboard)/anotacoes/page.tsx index 5dfae8f..e9cf42e 100644 --- a/app/(dashboard)/anotacoes/page.tsx +++ b/app/(dashboard)/anotacoes/page.tsx @@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server"; import { fetchNotesForUser } from "./data"; export default async function Page() { - const userId = await getUserId(); - const notes = await fetchNotesForUser(userId); + const userId = await getUserId(); + const notes = await fetchNotesForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts index fb812b8..adbd215 100644 --- a/app/(dashboard)/calendario/data.ts +++ b/app/(dashboard)/calendario/data.ts @@ -1,19 +1,18 @@ +import { and, eq, gte, lte, ne, or } from "drizzle-orm"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; +import type { + CalendarData, + CalendarEvent, +} from "@/components/calendario/types"; import { cartoes, lancamentos } from "@/db/schema"; import { db } from "@/lib/db"; import { - buildOptionSets, - buildSluggedFilters, - fetchLancamentoFilterSources, - mapLancamentosData, + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, + mapLancamentosData, } from "@/lib/lancamentos/page-helpers"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { and, eq, gte, lte, ne, or } from "drizzle-orm"; - -import type { - CalendarData, - CalendarEvent, -} from "@/components/calendario/types"; const PAYMENT_METHOD_BOLETO = "Boleto"; const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência"; @@ -21,200 +20,199 @@ const TRANSACTION_TYPE_TRANSFERENCIA = "Transferência"; const toDateKey = (date: Date) => date.toISOString().slice(0, 10); const parsePeriod = (period: string) => { - const [yearStr, monthStr] = period.split("-"); - const year = Number.parseInt(yearStr ?? "", 10); - const month = Number.parseInt(monthStr ?? "", 10); + const [yearStr, monthStr] = period.split("-"); + const year = Number.parseInt(yearStr ?? "", 10); + const month = Number.parseInt(monthStr ?? "", 10); - if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) { - throw new Error(`Período inválido: ${period}`); - } + if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) { + throw new Error(`Período inválido: ${period}`); + } - return { year, monthIndex: month - 1 }; + return { year, monthIndex: month - 1 }; }; const clampDayInMonth = (year: number, monthIndex: number, day: number) => { - const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); - if (day < 1) return 1; - if (day > lastDay) return lastDay; - return day; + const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); + if (day < 1) return 1; + if (day > lastDay) return lastDay; + return day; }; const isWithinRange = (value: string | null, start: string, end: string) => { - if (!value) return false; - return value >= start && value <= end; + if (!value) return false; + return value >= start && value <= end; }; type FetchCalendarDataParams = { - userId: string; - period: string; + userId: string; + period: string; }; export const fetchCalendarData = async ({ - userId, - period, + userId, + period, }: FetchCalendarDataParams): Promise => { - const { year, monthIndex } = parsePeriod(period); - const rangeStart = new Date(Date.UTC(year, monthIndex, 1)); - const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0)); - const rangeStartKey = toDateKey(rangeStart); - const rangeEndKey = toDateKey(rangeEnd); + const { year, monthIndex } = parsePeriod(period); + const rangeStart = new Date(Date.UTC(year, monthIndex, 1)); + const rangeEnd = new Date(Date.UTC(year, monthIndex + 1, 0)); + const rangeStartKey = toDateKey(rangeStart); + const rangeEndKey = toDateKey(rangeEnd); - const [lancamentoRows, cardRows, filterSources] = - await Promise.all([ - db.query.lancamentos.findMany({ - where: and( - eq(lancamentos.userId, userId), - ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA), - or( - // Lançamentos cuja data de compra esteja no período do calendário - and( - gte(lancamentos.purchaseDate, rangeStart), - lte(lancamentos.purchaseDate, rangeEnd) - ), - // Boletos cuja data de vencimento esteja no período do calendário - and( - eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), - gte(lancamentos.dueDate, rangeStart), - lte(lancamentos.dueDate, rangeEnd) - ), - // Lançamentos de cartão do período (para calcular totais de vencimento) - and( - eq(lancamentos.period, period), - ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO) - ) - ) - ), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - }), - db.query.cartoes.findMany({ - where: eq(cartoes.userId, userId), - }), - fetchLancamentoFilterSources(userId), - ]); + const [lancamentoRows, cardRows, filterSources] = await Promise.all([ + db.query.lancamentos.findMany({ + where: and( + eq(lancamentos.userId, userId), + ne(lancamentos.transactionType, TRANSACTION_TYPE_TRANSFERENCIA), + or( + // Lançamentos cuja data de compra esteja no período do calendário + and( + gte(lancamentos.purchaseDate, rangeStart), + lte(lancamentos.purchaseDate, rangeEnd), + ), + // Boletos cuja data de vencimento esteja no período do calendário + and( + eq(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), + gte(lancamentos.dueDate, rangeStart), + lte(lancamentos.dueDate, rangeEnd), + ), + // Lançamentos de cartão do período (para calcular totais de vencimento) + and( + eq(lancamentos.period, period), + ne(lancamentos.paymentMethod, PAYMENT_METHOD_BOLETO), + ), + ), + ), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + }), + db.query.cartoes.findMany({ + where: eq(cartoes.userId, userId), + }), + fetchLancamentoFilterSources(userId), + ]); - const lancamentosData = mapLancamentosData(lancamentoRows); - const events: CalendarEvent[] = []; + const lancamentosData = mapLancamentosData(lancamentoRows); + const events: CalendarEvent[] = []; - const cardTotals = new Map(); - for (const item of lancamentosData) { - if ( - !item.cartaoId || - item.period !== period || - item.pagadorRole !== PAGADOR_ROLE_ADMIN - ) { - continue; - } - const amount = Math.abs(item.amount ?? 0); - cardTotals.set( - item.cartaoId, - (cardTotals.get(item.cartaoId) ?? 0) + amount - ); - } + const cardTotals = new Map(); + for (const item of lancamentosData) { + if ( + !item.cartaoId || + item.period !== period || + item.pagadorRole !== PAGADOR_ROLE_ADMIN + ) { + continue; + } + const amount = Math.abs(item.amount ?? 0); + cardTotals.set( + item.cartaoId, + (cardTotals.get(item.cartaoId) ?? 0) + amount, + ); + } - for (const item of lancamentosData) { - const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO; - const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN; + for (const item of lancamentosData) { + const isBoleto = item.paymentMethod === PAYMENT_METHOD_BOLETO; + const isAdminPagador = item.pagadorRole === PAGADOR_ROLE_ADMIN; - // Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin - if (isBoleto) { - if ( - isAdminPagador && - item.dueDate && - isWithinRange(item.dueDate, rangeStartKey, rangeEndKey) - ) { - events.push({ - id: `${item.id}:boleto`, - type: "boleto", - date: item.dueDate, - lancamento: item, - }); - } - } else { - // Para outros tipos de lançamento, exibir na data de compra - if (!isAdminPagador) { - continue; - } - const purchaseDateKey = item.purchaseDate.slice(0, 10); - if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) { - events.push({ - id: item.id, - type: "lancamento", - date: purchaseDateKey, - lancamento: item, - }); - } - } - } + // Para boletos, exibir apenas na data de vencimento e apenas se for pagador admin + if (isBoleto) { + if ( + isAdminPagador && + item.dueDate && + isWithinRange(item.dueDate, rangeStartKey, rangeEndKey) + ) { + events.push({ + id: `${item.id}:boleto`, + type: "boleto", + date: item.dueDate, + lancamento: item, + }); + } + } else { + // Para outros tipos de lançamento, exibir na data de compra + if (!isAdminPagador) { + continue; + } + const purchaseDateKey = item.purchaseDate.slice(0, 10); + if (isWithinRange(purchaseDateKey, rangeStartKey, rangeEndKey)) { + events.push({ + id: item.id, + type: "lancamento", + date: purchaseDateKey, + lancamento: item, + }); + } + } + } - // Exibir vencimentos apenas de cartões com lançamentos do pagador admin - for (const card of cardRows) { - if (!cardTotals.has(card.id)) { - continue; - } + // Exibir vencimentos apenas de cartões com lançamentos do pagador admin + for (const card of cardRows) { + if (!cardTotals.has(card.id)) { + continue; + } - const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10); - if (Number.isNaN(dueDayNumber)) { - continue; - } + const dueDayNumber = Number.parseInt(card.dueDay ?? "", 10); + if (Number.isNaN(dueDayNumber)) { + continue; + } - const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber); - const dueDateKey = toDateKey( - new Date(Date.UTC(year, monthIndex, normalizedDay)) - ); + const normalizedDay = clampDayInMonth(year, monthIndex, dueDayNumber); + const dueDateKey = toDateKey( + new Date(Date.UTC(year, monthIndex, normalizedDay)), + ); - events.push({ - id: `${card.id}:cartao`, - type: "cartao", - date: dueDateKey, - card: { - id: card.id, - name: card.name, - dueDay: card.dueDay, - closingDay: card.closingDay, - brand: card.brand ?? null, - status: card.status, - logo: card.logo ?? null, - totalDue: cardTotals.get(card.id) ?? null, - }, - }); - } + events.push({ + id: `${card.id}:cartao`, + type: "cartao", + date: dueDateKey, + card: { + id: card.id, + name: card.name, + dueDay: card.dueDay, + closingDay: card.closingDay, + brand: card.brand ?? null, + status: card.status, + logo: card.logo ?? null, + totalDue: cardTotals.get(card.id) ?? null, + }, + }); + } - const typePriority: Record = { - lancamento: 0, - boleto: 1, - cartao: 2, - }; + const typePriority: Record = { + lancamento: 0, + boleto: 1, + cartao: 2, + }; - events.sort((a, b) => { - if (a.date === b.date) { - return typePriority[a.type] - typePriority[b.type]; - } - return a.date.localeCompare(b.date); - }); + events.sort((a, b) => { + if (a.date === b.date) { + return typePriority[a.type] - typePriority[b.type]; + } + return a.date.localeCompare(b.date); + }); - const sluggedFilters = buildSluggedFilters(filterSources); - const optionSets = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); + const sluggedFilters = buildSluggedFilters(filterSources); + const optionSets = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); - const estabelecimentos = await getRecentEstablishmentsAction(); + const estabelecimentos = await getRecentEstablishmentsAction(); - return { - events, - formOptions: { - pagadorOptions: optionSets.pagadorOptions, - splitPagadorOptions: optionSets.splitPagadorOptions, - defaultPagadorId: optionSets.defaultPagadorId, - contaOptions: optionSets.contaOptions, - cartaoOptions: optionSets.cartaoOptions, - categoriaOptions: optionSets.categoriaOptions, - estabelecimentos, - }, - }; + return { + events, + formOptions: { + pagadorOptions: optionSets.pagadorOptions, + splitPagadorOptions: optionSets.splitPagadorOptions, + defaultPagadorId: optionSets.defaultPagadorId, + contaOptions: optionSets.contaOptions, + cartaoOptions: optionSets.cartaoOptions, + categoriaOptions: optionSets.categoriaOptions, + estabelecimentos, + }, + }; }; diff --git a/app/(dashboard)/calendario/layout.tsx b/app/(dashboard)/calendario/layout.tsx index 41263c7..a652354 100644 --- a/app/(dashboard)/calendario/layout.tsx +++ b/app/(dashboard)/calendario/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiCalendarEventLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Calendário | Opensheets", + title: "Calendário | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Calendário" - subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento." - /> - {children} -
- ); + return ( +
+ } + title="Calendário" + subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/calendario/loading.tsx b/app/(dashboard)/calendario/loading.tsx index 79ad556..fdb3579 100644 --- a/app/(dashboard)/calendario/loading.tsx +++ b/app/(dashboard)/calendario/loading.tsx @@ -5,55 +5,55 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos */ export default function CalendarioLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
- {/* Calendar Container */} -
- {/* Cabeçalho com dias da semana */} -
- {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => ( -
- -
- ))} -
+ {/* Calendar Container */} +
+ {/* Cabeçalho com dias da semana */} +
+ {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => ( +
+ +
+ ))} +
- {/* Grade de dias (6 semanas) */} -
- {Array.from({ length: 42 }).map((_, i) => ( -
- {/* Número do dia */} - + {/* Grade de dias (6 semanas) */} +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ {/* Número do dia */} + - {/* Indicadores de eventos (aleatório entre 0-3) */} - {i % 3 === 0 && ( -
- - {i % 5 === 0 && ( - - )} -
- )} -
- ))} -
+ {/* Indicadores de eventos (aleatório entre 0-3) */} + {i % 3 === 0 && ( +
+ + {i % 5 === 0 && ( + + )} +
+ )} +
+ ))} +
- {/* Legenda */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - -
- ))} -
-
-
- ); + {/* Legenda */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); } diff --git a/app/(dashboard)/calendario/page.tsx b/app/(dashboard)/calendario/page.tsx index 35c914b..4bd0e12 100644 --- a/app/(dashboard)/calendario/page.tsx +++ b/app/(dashboard)/calendario/page.tsx @@ -1,47 +1,46 @@ +import { MonthlyCalendar } from "@/components/calendario/monthly-calendar"; +import type { CalendarPeriod } from "@/components/calendario/types"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUserId } from "@/lib/auth/server"; import { - getSingleParam, - type ResolvedSearchParams, + getSingleParam, + type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { parsePeriodParam } from "@/lib/utils/period"; - -import { MonthlyCalendar } from "@/components/calendario/monthly-calendar"; import { fetchCalendarData } from "./data"; -import type { CalendarPeriod } from "@/components/calendario/types"; type PageSearchParams = Promise; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; export default async function Page({ searchParams }: PageProps) { - const userId = await getUserId(); - const resolvedParams = searchParams ? await searchParams : undefined; + const userId = await getUserId(); + const resolvedParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedParams, "periodo"); - const { period, monthName, year } = parsePeriodParam(periodoParam); + const periodoParam = getSingleParam(resolvedParams, "periodo"); + const { period, monthName, year } = parsePeriodParam(periodoParam); - const calendarData = await fetchCalendarData({ - userId, - period, - }); + const calendarData = await fetchCalendarData({ + userId, + period, + }); - const calendarPeriod: CalendarPeriod = { - period, - monthName, - year, - }; + const calendarPeriod: CalendarPeriod = { + period, + monthName, + year, + }; - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts index 1471403..fcd4d71 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts @@ -1,119 +1,117 @@ "use server"; -import { - cartoes, - categorias, - faturas, - lancamentos, - pagadores, -} from "@/db/schema"; -import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { - INVOICE_PAYMENT_STATUS, - INVOICE_STATUS_VALUES, - PERIOD_FORMAT_REGEX, - type InvoicePaymentStatus, -} from "@/lib/faturas"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { parseLocalDateString } from "@/lib/utils/date"; import { and, eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { + cartoes, + categorias, + faturas, + lancamentos, + pagadores, +} from "@/db/schema"; +import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { + INVOICE_PAYMENT_STATUS, + INVOICE_STATUS_VALUES, + type InvoicePaymentStatus, + PERIOD_FORMAT_REGEX, +} from "@/lib/faturas"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { parseLocalDateString } from "@/lib/utils/date"; const updateInvoicePaymentStatusSchema = z.object({ - cartaoId: z - .string({ message: "Cartão inválido." }) - .uuid("Cartão inválido."), - period: z - .string({ message: "Período inválido." }) - .regex(PERIOD_FORMAT_REGEX, "Período inválido."), - status: z.enum( - INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]] - ), - paymentDate: z.string().optional(), + cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), + period: z + .string({ message: "Período inválido." }) + .regex(PERIOD_FORMAT_REGEX, "Período inválido."), + status: z.enum( + INVOICE_STATUS_VALUES as [InvoicePaymentStatus, ...InvoicePaymentStatus[]], + ), + paymentDate: z.string().optional(), }); type UpdateInvoicePaymentStatusInput = z.infer< - typeof updateInvoicePaymentStatusSchema + typeof updateInvoicePaymentStatusSchema >; type ActionResult = - | { success: true; message: string } - | { success: false; error: string }; + | { success: true; message: string } + | { success: false; error: string }; const successMessageByStatus: Record = { - [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.", - [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", + [INVOICE_PAYMENT_STATUS.PAID]: "Fatura marcada como paga.", + [INVOICE_PAYMENT_STATUS.PENDING]: "Pagamento da fatura foi revertido.", }; const formatDecimal = (value: number) => - (Math.round(value * 100) / 100).toFixed(2); + (Math.round(value * 100) / 100).toFixed(2); export async function updateInvoicePaymentStatusAction( - input: UpdateInvoicePaymentStatusInput + input: UpdateInvoicePaymentStatusInput, ): Promise { - try { - const user = await getUser(); - const data = updateInvoicePaymentStatusSchema.parse(input); + try { + const user = await getUser(); + const data = updateInvoicePaymentStatusSchema.parse(input); - await db.transaction(async (tx: typeof db) => { - const card = await tx.query.cartoes.findFirst({ - columns: { id: true, contaId: true, name: true }, - where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), - }); + await db.transaction(async (tx: typeof db) => { + const card = await tx.query.cartoes.findFirst({ + columns: { id: true, contaId: true, name: true }, + where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), + }); - if (!card) { - throw new Error("Cartão não encontrado."); - } + if (!card) { + throw new Error("Cartão não encontrado."); + } - const existingInvoice = await tx.query.faturas.findFirst({ - columns: { - id: true, - }, - where: and( - eq(faturas.cartaoId, data.cartaoId), - eq(faturas.userId, user.id), - eq(faturas.period, data.period) - ), - }); + const existingInvoice = await tx.query.faturas.findFirst({ + columns: { + id: true, + }, + where: and( + eq(faturas.cartaoId, data.cartaoId), + eq(faturas.userId, user.id), + eq(faturas.period, data.period), + ), + }); - if (existingInvoice) { - await tx - .update(faturas) - .set({ - paymentStatus: data.status, - }) - .where(eq(faturas.id, existingInvoice.id)); - } else { - await tx.insert(faturas).values({ - cartaoId: data.cartaoId, - period: data.period, - paymentStatus: data.status, - userId: user.id, - }); - } + if (existingInvoice) { + await tx + .update(faturas) + .set({ + paymentStatus: data.status, + }) + .where(eq(faturas.id, existingInvoice.id)); + } else { + await tx.insert(faturas).values({ + cartaoId: data.cartaoId, + period: data.period, + paymentStatus: data.status, + userId: user.id, + }); + } - const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID; + const shouldMarkAsPaid = data.status === INVOICE_PAYMENT_STATUS.PAID; - await tx - .update(lancamentos) - .set({ isSettled: shouldMarkAsPaid }) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.cartaoId, card.id), - eq(lancamentos.period, data.period) - ) - ); + await tx + .update(lancamentos) + .set({ isSettled: shouldMarkAsPaid }) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.cartaoId, card.id), + eq(lancamentos.period, data.period), + ), + ); - const invoiceNote = buildInvoicePaymentNote(card.id, data.period); + const invoiceNote = buildInvoicePaymentNote(card.id, data.period); - if (shouldMarkAsPaid) { - const [adminShareRow] = await tx - .select({ - total: sql` + if (shouldMarkAsPaid) { + const [adminShareRow] = await tx + .select({ + total: sql` coalesce( sum( case @@ -124,177 +122,175 @@ export async function updateInvoicePaymentStatusAction( 0 ) `, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.cartaoId, card.id), - eq(lancamentos.period, data.period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ); + }) + .from(lancamentos) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.cartaoId, card.id), + eq(lancamentos.period, data.period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ); - const adminShare = Math.abs(Number(adminShareRow?.total ?? 0)); + const adminShare = Math.abs(Number(adminShareRow?.total ?? 0)); - if (adminShare > 0 && card.contaId) { - const adminPagador = await tx.query.pagadores.findFirst({ - columns: { id: true }, - where: and( - eq(pagadores.userId, user.id), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ), - }); + if (adminShare > 0 && card.contaId) { + const adminPagador = await tx.query.pagadores.findFirst({ + columns: { id: true }, + where: and( + eq(pagadores.userId, user.id), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + }); - const paymentCategory = await tx.query.categorias.findFirst({ - columns: { id: true }, - where: and( - eq(categorias.userId, user.id), - eq(categorias.name, "Pagamentos") - ), - }); + const paymentCategory = await tx.query.categorias.findFirst({ + columns: { id: true }, + where: and( + eq(categorias.userId, user.id), + eq(categorias.name, "Pagamentos"), + ), + }); - if (adminPagador) { - // Usar a data customizada ou a data atual como data de pagamento - const invoiceDate = data.paymentDate - ? parseLocalDateString(data.paymentDate) - : new Date(); + if (adminPagador) { + // Usar a data customizada ou a data atual como data de pagamento + const invoiceDate = data.paymentDate + ? parseLocalDateString(data.paymentDate) + : new Date(); - const amount = `-${formatDecimal(adminShare)}`; - const payload = { - condition: "À vista", - name: `Pagamento fatura - ${card.name}`, - paymentMethod: "Pix", - note: invoiceNote, - amount, - purchaseDate: invoiceDate, - transactionType: "Despesa" as const, - period: data.period, - isSettled: true, - userId: user.id, - contaId: card.contaId, - categoriaId: paymentCategory?.id ?? null, - pagadorId: adminPagador.id, - }; + const amount = `-${formatDecimal(adminShare)}`; + const payload = { + condition: "À vista", + name: `Pagamento fatura - ${card.name}`, + paymentMethod: "Pix", + note: invoiceNote, + amount, + purchaseDate: invoiceDate, + transactionType: "Despesa" as const, + period: data.period, + isSettled: true, + userId: user.id, + contaId: card.contaId, + categoriaId: paymentCategory?.id ?? null, + pagadorId: adminPagador.id, + }; - const existingPayment = await tx.query.lancamentos.findFirst({ - columns: { id: true }, - where: and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote) - ), - }); + const existingPayment = await tx.query.lancamentos.findFirst({ + columns: { id: true }, + where: and( + eq(lancamentos.userId, user.id), + eq(lancamentos.note, invoiceNote), + ), + }); - if (existingPayment) { - await tx - .update(lancamentos) - .set(payload) - .where(eq(lancamentos.id, existingPayment.id)); - } else { - await tx.insert(lancamentos).values(payload); - } - } - } - } else { - await tx - .delete(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote) - ) - ); - } - }); + if (existingPayment) { + await tx + .update(lancamentos) + .set(payload) + .where(eq(lancamentos.id, existingPayment.id)); + } else { + await tx.insert(lancamentos).values(payload); + } + } + } + } else { + await tx + .delete(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.note, invoiceNote), + ), + ); + } + }); - revalidatePath(`/cartoes/${data.cartaoId}/fatura`); - revalidatePath("/cartoes"); - revalidatePath("/contas"); + revalidatePath(`/cartoes/${data.cartaoId}/fatura`); + revalidatePath("/cartoes"); + revalidatePath("/contas"); - return { success: true, message: successMessageByStatus[data.status] }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message ?? "Dados inválidos.", - }; - } + return { success: true, message: successMessageByStatus[data.status] }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } - return { - success: false, - error: error instanceof Error ? error.message : "Erro inesperado.", - }; - } + return { + success: false, + error: error instanceof Error ? error.message : "Erro inesperado.", + }; + } } const updatePaymentDateSchema = z.object({ - cartaoId: z - .string({ message: "Cartão inválido." }) - .uuid("Cartão inválido."), - period: z - .string({ message: "Período inválido." }) - .regex(PERIOD_FORMAT_REGEX, "Período inválido."), - paymentDate: z.string({ message: "Data de pagamento inválida." }), + cartaoId: z.string({ message: "Cartão inválido." }).uuid("Cartão inválido."), + period: z + .string({ message: "Período inválido." }) + .regex(PERIOD_FORMAT_REGEX, "Período inválido."), + paymentDate: z.string({ message: "Data de pagamento inválida." }), }); type UpdatePaymentDateInput = z.infer; export async function updatePaymentDateAction( - input: UpdatePaymentDateInput + input: UpdatePaymentDateInput, ): Promise { - try { - const user = await getUser(); - const data = updatePaymentDateSchema.parse(input); + try { + const user = await getUser(); + const data = updatePaymentDateSchema.parse(input); - await db.transaction(async (tx: typeof db) => { - const card = await tx.query.cartoes.findFirst({ - columns: { id: true }, - where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), - }); + await db.transaction(async (tx: typeof db) => { + const card = await tx.query.cartoes.findFirst({ + columns: { id: true }, + where: and(eq(cartoes.id, data.cartaoId), eq(cartoes.userId, user.id)), + }); - if (!card) { - throw new Error("Cartão não encontrado."); - } + if (!card) { + throw new Error("Cartão não encontrado."); + } - const invoiceNote = buildInvoicePaymentNote(card.id, data.period); + const invoiceNote = buildInvoicePaymentNote(card.id, data.period); - const existingPayment = await tx.query.lancamentos.findFirst({ - columns: { id: true }, - where: and( - eq(lancamentos.userId, user.id), - eq(lancamentos.note, invoiceNote) - ), - }); + const existingPayment = await tx.query.lancamentos.findFirst({ + columns: { id: true }, + where: and( + eq(lancamentos.userId, user.id), + eq(lancamentos.note, invoiceNote), + ), + }); - if (!existingPayment) { - throw new Error("Pagamento não encontrado."); - } + if (!existingPayment) { + throw new Error("Pagamento não encontrado."); + } - await tx - .update(lancamentos) - .set({ - purchaseDate: parseLocalDateString(data.paymentDate), - }) - .where(eq(lancamentos.id, existingPayment.id)); - }); + await tx + .update(lancamentos) + .set({ + purchaseDate: parseLocalDateString(data.paymentDate), + }) + .where(eq(lancamentos.id, existingPayment.id)); + }); - revalidatePath(`/cartoes/${data.cartaoId}/fatura`); - revalidatePath("/cartoes"); - revalidatePath("/contas"); + revalidatePath(`/cartoes/${data.cartaoId}/fatura`); + revalidatePath("/cartoes"); + revalidatePath("/contas"); - return { success: true, message: "Data de pagamento atualizada." }; - } catch (error) { - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message ?? "Dados inválidos.", - }; - } + return { success: true, message: "Data de pagamento atualizada." }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } - return { - success: false, - error: error instanceof Error ? error.message : "Erro inesperado.", - }; - } + return { + success: false, + error: error instanceof Error ? error.message : "Erro inesperado.", + }; + } } diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts index bf4ec97..6c5322d 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts @@ -1,104 +1,117 @@ +import { and, desc, eq, type SQL, sum } from "drizzle-orm"; import { cartoes, faturas, lancamentos } from "@/db/schema"; import { buildInvoicePaymentNote } from "@/lib/accounts/constants"; import { db } from "@/lib/db"; import { - INVOICE_PAYMENT_STATUS, - type InvoicePaymentStatus, + INVOICE_PAYMENT_STATUS, + type InvoicePaymentStatus, } from "@/lib/faturas"; -import { and, eq, sum } from "drizzle-orm"; const toNumber = (value: string | number | null | undefined) => { - if (typeof value === "number") { - return value; - } - if (value === null || value === undefined) { - return 0; - } - const parsed = Number(value); - return Number.isNaN(parsed) ? 0 : parsed; + if (typeof value === "number") { + return value; + } + if (value === null || value === undefined) { + return 0; + } + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; }; export async function fetchCardData(userId: string, cartaoId: string) { - const card = await db.query.cartoes.findFirst({ - columns: { - id: true, - name: true, - brand: true, - closingDay: true, - dueDay: true, - logo: true, - limit: true, - status: true, - note: true, - contaId: true, - }, - where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), - }); + const card = await db.query.cartoes.findFirst({ + columns: { + id: true, + name: true, + brand: true, + closingDay: true, + dueDay: true, + logo: true, + limit: true, + status: true, + note: true, + contaId: true, + }, + where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), + }); - return card; + return card; } export async function fetchInvoiceData( - userId: string, - cartaoId: string, - selectedPeriod: string + userId: string, + cartaoId: string, + selectedPeriod: string, ): Promise<{ - totalAmount: number; - invoiceStatus: InvoicePaymentStatus; - paymentDate: Date | null; + totalAmount: number; + invoiceStatus: InvoicePaymentStatus; + paymentDate: Date | null; }> { - const [invoiceRow, totalRow] = await Promise.all([ - db.query.faturas.findFirst({ - columns: { - id: true, - period: true, - paymentStatus: true, - }, - where: and( - eq(faturas.cartaoId, cartaoId), - eq(faturas.userId, userId), - eq(faturas.period, selectedPeriod) - ), - }), - db - .select({ totalAmount: sum(lancamentos.amount) }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.cartaoId, cartaoId), - eq(lancamentos.period, selectedPeriod) - ) - ), - ]); + const [invoiceRow, totalRow] = await Promise.all([ + db.query.faturas.findFirst({ + columns: { + id: true, + period: true, + paymentStatus: true, + }, + where: and( + eq(faturas.cartaoId, cartaoId), + eq(faturas.userId, userId), + eq(faturas.period, selectedPeriod), + ), + }), + db + .select({ totalAmount: sum(lancamentos.amount) }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.cartaoId, cartaoId), + eq(lancamentos.period, selectedPeriod), + ), + ), + ]); - const totalAmount = toNumber(totalRow[0]?.totalAmount); - const isInvoiceStatus = ( - value: string | null | undefined - ): value is InvoicePaymentStatus => - !!value && ["pendente", "pago"].includes(value); + const totalAmount = toNumber(totalRow[0]?.totalAmount); + const isInvoiceStatus = ( + value: string | null | undefined, + ): value is InvoicePaymentStatus => + !!value && ["pendente", "pago"].includes(value); - const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus) - ? invoiceRow?.paymentStatus - : INVOICE_PAYMENT_STATUS.PENDING; + const invoiceStatus = isInvoiceStatus(invoiceRow?.paymentStatus) + ? invoiceRow?.paymentStatus + : INVOICE_PAYMENT_STATUS.PENDING; - // Buscar data do pagamento se a fatura estiver paga - let paymentDate: Date | null = null; - if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) { - const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod); - const paymentLancamento = await db.query.lancamentos.findFirst({ - columns: { - purchaseDate: true, - }, - where: and( - eq(lancamentos.userId, userId), - eq(lancamentos.note, invoiceNote) - ), - }); - paymentDate = paymentLancamento?.purchaseDate - ? new Date(paymentLancamento.purchaseDate) - : null; - } + // Buscar data do pagamento se a fatura estiver paga + let paymentDate: Date | null = null; + if (invoiceStatus === INVOICE_PAYMENT_STATUS.PAID) { + const invoiceNote = buildInvoicePaymentNote(cartaoId, selectedPeriod); + const paymentLancamento = await db.query.lancamentos.findFirst({ + columns: { + purchaseDate: true, + }, + where: and( + eq(lancamentos.userId, userId), + eq(lancamentos.note, invoiceNote), + ), + }); + paymentDate = paymentLancamento?.purchaseDate + ? new Date(paymentLancamento.purchaseDate) + : null; + } - return { totalAmount, invoiceStatus, paymentDate }; + return { totalAmount, invoiceStatus, paymentDate }; +} + +export async function fetchCardLancamentos(filters: SQL[]) { + return db.query.lancamentos.findMany({ + where: and(...filters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: desc(lancamentos.purchaseDate), + }); } diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx index 6af1b25..82394e7 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx @@ -1,7 +1,7 @@ import { - FilterSkeleton, - InvoiceSummaryCardSkeleton, - TransactionsTableSkeleton, + FilterSkeleton, + InvoiceSummaryCardSkeleton, + TransactionsTableSkeleton, } from "@/components/skeletons"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,32 +10,32 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos */ export default function FaturaLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
- {/* Invoice Summary Card */} -
- -
+ {/* Invoice Summary Card */} +
+ +
- {/* Seção de lançamentos */} -
-
- {/* Header */} -
- - -
+ {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ + +
- {/* Filtros */} - + {/* Filtros */} + - {/* Tabela */} - -
-
-
- ); + {/* Tabela */} + +
+ +
+ ); } diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx index ae566be..7b5e6a3 100644 --- a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -1,3 +1,5 @@ +import { RiPencilLine } from "@remixicon/react"; +import { notFound } from "next/navigation"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { CardDialog } from "@/components/cartoes/card-dialog"; import type { Card } from "@/components/cartoes/types"; @@ -5,204 +7,187 @@ import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { Button } from "@/components/ui/button"; -import { lancamentos, type Conta } from "@/db/schema"; -import { db } from "@/lib/db"; +import type { Conta } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type ResolvedSearchParams, + buildLancamentoWhere, + buildOptionSets, + buildSluggedFilters, + buildSlugMaps, + extractLancamentoSearchFilters, + fetchLancamentoFilterSources, + getSingleParam, + mapLancamentosData, + type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { loadLogoOptions } from "@/lib/logo/options"; import { parsePeriodParam } from "@/lib/utils/period"; -import { RiPencilLine } from "@remixicon/react"; -import { and, desc } from "drizzle-orm"; -import { notFound } from "next/navigation"; -import { fetchCardData, fetchInvoiceData } from "./data"; +import { fetchCardData, fetchCardLancamentos, fetchInvoiceData } from "./data"; type PageSearchParams = Promise; type PageProps = { - params: Promise<{ cartaoId: string }>; - searchParams?: PageSearchParams; + params: Promise<{ cartaoId: string }>; + searchParams?: PageSearchParams; }; export default async function Page({ params, searchParams }: PageProps) { - const { cartaoId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; + const { cartaoId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName, - year, - } = parsePeriodParam(periodoParamRaw); + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { + period: selectedPeriod, + monthName, + year, + } = parsePeriodParam(periodoParamRaw); - const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const card = await fetchCardData(userId, cartaoId); + const card = await fetchCardData(userId, cartaoId); - if (!card) { - notFound(); - } + if (!card) { + notFound(); + } - const [ - filterSources, - logoOptions, - invoiceData, - estabelecimentos, - ] = await Promise.all([ - fetchLancamentoFilterSources(userId), - loadLogoOptions(), - fetchInvoiceData(userId, cartaoId, selectedPeriod), - getRecentEstablishmentsAction(), - ]); - const sluggedFilters = buildSluggedFilters(filterSources); - const slugMaps = buildSlugMaps(sluggedFilters); + const [filterSources, logoOptions, invoiceData, estabelecimentos] = + await Promise.all([ + fetchLancamentoFilterSources(userId), + loadLogoOptions(), + fetchInvoiceData(userId, cartaoId, selectedPeriod), + getRecentEstablishmentsAction(), + ]); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); - const filters = buildLancamentoWhere({ - userId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - cardId: card.id, - }); + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + cardId: card.id, + }); - const lancamentoRows = await db.query.lancamentos.findMany({ - where: and(...filters), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - orderBy: desc(lancamentos.purchaseDate), - }); + const lancamentoRows = await fetchCardLancamentos(filters); - const lancamentosData = mapLancamentosData(lancamentoRows); + const lancamentosData = mapLancamentosData(lancamentoRows); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - limitCartaoId: card.id, - }); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + limitCartaoId: card.id, + }); - const accountOptions = filterSources.contaRows.map((conta: Conta) => ({ - id: conta.id, - name: conta.name ?? "Conta", - })); + const accountOptions = filterSources.contaRows.map((conta: Conta) => ({ + id: conta.id, + name: conta.name ?? "Conta", + })); - const contaName = - filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId) - ?.name ?? "Conta"; + const contaName = + filterSources.contaRows.find((conta: Conta) => conta.id === card.contaId) + ?.name ?? "Conta"; - const cardDialogData: Card = { - id: card.id, - name: card.name, - brand: card.brand ?? "", - status: card.status ?? "", - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note ?? null, - logo: card.logo, - limit: - card.limit !== null && card.limit !== undefined - ? Number(card.limit) - : null, - contaId: card.contaId, - contaName, - limitInUse: null, - limitAvailable: null, - }; + const cardDialogData: Card = { + id: card.id, + name: card.name, + brand: card.brand ?? "", + status: card.status ?? "", + closingDay: card.closingDay, + dueDay: card.dueDay, + note: card.note ?? null, + logo: card.logo, + limit: + card.limit !== null && card.limit !== undefined + ? Number(card.limit) + : null, + contaId: card.contaId, + contaName, + limitInUse: null, + limitAvailable: null, + }; - const { totalAmount, invoiceStatus, paymentDate } = invoiceData; - const limitAmount = - card.limit !== null && card.limit !== undefined ? Number(card.limit) : null; + const { totalAmount, invoiceStatus, paymentDate } = invoiceData; + const limitAmount = + card.limit !== null && card.limit !== undefined ? Number(card.limit) : null; - const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice( - 1 - )} de ${year}`; + const periodLabel = `${monthName.charAt(0).toUpperCase()}${monthName.slice( + 1, + )} de ${year}`; - return ( -
- + return ( +
+ -
- - - - } - /> - } - /> -
+
+ + + + } + /> + } + /> +
-
- -
-
- ); +
+ +
+
+ ); } diff --git a/app/(dashboard)/cartoes/actions.ts b/app/(dashboard)/cartoes/actions.ts index 233a3c6..1d1fe7b 100644 --- a/app/(dashboard)/cartoes/actions.ts +++ b/app/(dashboard)/cartoes/actions.ts @@ -1,51 +1,54 @@ "use server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { cartoes, contas } from "@/db/schema"; -import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; -import { revalidateForEntity } from "@/lib/actions/helpers"; import { - dayOfMonthSchema, - noteSchema, - optionalDecimalSchema, - uuidSchema, + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { + dayOfMonthSchema, + noteSchema, + optionalDecimalSchema, + uuidSchema, } from "@/lib/schemas/common"; import { formatDecimalForDb } from "@/lib/utils/currency"; import { normalizeFilePath } from "@/lib/utils/string"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; const cardBaseSchema = z.object({ - name: z - .string({ message: "Informe o nome do cartão." }) - .trim() - .min(1, "Informe o nome do cartão."), - brand: z - .string({ message: "Informe a bandeira." }) - .trim() - .min(1, "Informe a bandeira."), - status: z - .string({ message: "Informe o status do cartão." }) - .trim() - .min(1, "Informe o status do cartão."), - closingDay: dayOfMonthSchema, - dueDay: dayOfMonthSchema, - note: noteSchema, - limit: optionalDecimalSchema, - logo: z - .string({ message: "Selecione um logo." }) - .trim() - .min(1, "Selecione um logo."), - contaId: uuidSchema("Conta"), + name: z + .string({ message: "Informe o nome do cartão." }) + .trim() + .min(1, "Informe o nome do cartão."), + brand: z + .string({ message: "Informe a bandeira." }) + .trim() + .min(1, "Informe a bandeira."), + status: z + .string({ message: "Informe o status do cartão." }) + .trim() + .min(1, "Informe o status do cartão."), + closingDay: dayOfMonthSchema, + dueDay: dayOfMonthSchema, + note: noteSchema, + limit: optionalDecimalSchema, + logo: z + .string({ message: "Selecione um logo." }) + .trim() + .min(1, "Selecione um logo."), + contaId: uuidSchema("Conta"), }); const createCardSchema = cardBaseSchema; const updateCardSchema = cardBaseSchema.extend({ - id: uuidSchema("Cartão"), + id: uuidSchema("Cartão"), }); const deleteCardSchema = z.object({ - id: uuidSchema("Cartão"), + id: uuidSchema("Cartão"), }); type CardCreateInput = z.infer; @@ -53,113 +56,113 @@ type CardUpdateInput = z.infer; type CardDeleteInput = z.infer; async function assertAccountOwnership(userId: string, contaId: string) { - const account = await db.query.contas.findFirst({ - columns: { id: true }, - where: and(eq(contas.id, contaId), eq(contas.userId, userId)), - }); + const account = await db.query.contas.findFirst({ + columns: { id: true }, + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); - if (!account) { - throw new Error("Conta vinculada não encontrada."); - } + if (!account) { + throw new Error("Conta vinculada não encontrada."); + } } export async function createCardAction( - input: CardCreateInput + input: CardCreateInput, ): Promise { - try { - const user = await getUser(); - const data = createCardSchema.parse(input); + try { + const user = await getUser(); + const data = createCardSchema.parse(input); - await assertAccountOwnership(user.id, data.contaId); + await assertAccountOwnership(user.id, data.contaId); - const logoFile = normalizeFilePath(data.logo); + const logoFile = normalizeFilePath(data.logo); - await db.insert(cartoes).values({ - name: data.name, - brand: data.brand, - status: data.status, - closingDay: data.closingDay, - dueDay: data.dueDay, - note: data.note ?? null, - limit: formatDecimalForDb(data.limit), - logo: logoFile, - contaId: data.contaId, - userId: user.id, - }); + await db.insert(cartoes).values({ + name: data.name, + brand: data.brand, + status: data.status, + closingDay: data.closingDay, + dueDay: data.dueDay, + note: data.note ?? null, + limit: formatDecimalForDb(data.limit), + logo: logoFile, + contaId: data.contaId, + userId: user.id, + }); - revalidateForEntity("cartoes"); + revalidateForEntity("cartoes"); - return { success: true, message: "Cartão criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Cartão criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updateCardAction( - input: CardUpdateInput + input: CardUpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateCardSchema.parse(input); + try { + const user = await getUser(); + const data = updateCardSchema.parse(input); - await assertAccountOwnership(user.id, data.contaId); + await assertAccountOwnership(user.id, data.contaId); - const logoFile = normalizeFilePath(data.logo); + const logoFile = normalizeFilePath(data.logo); - const [updated] = await db - .update(cartoes) - .set({ - name: data.name, - brand: data.brand, - status: data.status, - closingDay: data.closingDay, - dueDay: data.dueDay, - note: data.note ?? null, - limit: formatDecimalForDb(data.limit), - logo: logoFile, - contaId: data.contaId, - }) - .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) - .returning(); + const [updated] = await db + .update(cartoes) + .set({ + name: data.name, + brand: data.brand, + status: data.status, + closingDay: data.closingDay, + dueDay: data.dueDay, + note: data.note ?? null, + limit: formatDecimalForDb(data.limit), + logo: logoFile, + contaId: data.contaId, + }) + .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) + .returning(); - if (!updated) { - return { - success: false, - error: "Cartão não encontrado.", - }; - } + if (!updated) { + return { + success: false, + error: "Cartão não encontrado.", + }; + } - revalidateForEntity("cartoes"); + revalidateForEntity("cartoes"); - return { success: true, message: "Cartão atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Cartão atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deleteCardAction( - input: CardDeleteInput + input: CardDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteCardSchema.parse(input); + try { + const user = await getUser(); + const data = deleteCardSchema.parse(input); - const [deleted] = await db - .delete(cartoes) - .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) - .returning({ id: cartoes.id }); + const [deleted] = await db + .delete(cartoes) + .where(and(eq(cartoes.id, data.id), eq(cartoes.userId, user.id))) + .returning({ id: cartoes.id }); - if (!deleted) { - return { - success: false, - error: "Cartão não encontrado.", - }; - } + if (!deleted) { + return { + success: false, + error: "Cartão não encontrado.", + }; + } - revalidateForEntity("cartoes"); + revalidateForEntity("cartoes"); - return { success: true, message: "Cartão removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Cartão removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/cartoes/data.ts b/app/(dashboard)/cartoes/data.ts index 9b10a19..89c7acb 100644 --- a/app/(dashboard)/cartoes/data.ts +++ b/app/(dashboard)/cartoes/data.ts @@ -4,191 +4,210 @@ import { loadLogoOptions } from "@/lib/logo/options"; import { and, eq, ilike, isNull, not, or, sql } from "drizzle-orm"; export type CardData = { - id: string; - name: string; - brand: string | null; - status: string | null; - closingDay: number; - dueDay: number; - note: string | null; - logo: string | null; - limit: number | null; - limitInUse: number; - limitAvailable: number | null; - contaId: string; - contaName: string; + id: string; + name: string; + brand: string | null; + status: string | null; + closingDay: number; + dueDay: number; + note: string | null; + logo: string | null; + limit: number | null; + limitInUse: number; + limitAvailable: number | null; + contaId: string; + contaName: string; }; export type AccountSimple = { - id: string; - name: string; - logo: string | null; + id: string; + name: string; + logo: string | null; }; export async function fetchCardsForUser(userId: string): Promise<{ - cards: CardData[]; - accounts: AccountSimple[]; - logoOptions: LogoOption[]; + cards: CardData[]; + accounts: AccountSimple[]; + logoOptions: LogoOption[]; }> { - const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ - db.query.cartoes.findMany({ - orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)], - where: and(eq(cartoes.userId, userId), not(ilike(cartoes.status, "inativo"))), - with: { - conta: { - columns: { - id: true, - name: true, - }, - }, - }, - }), - db.query.contas.findMany({ - orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], - where: eq(contas.userId, userId), - columns: { - id: true, - name: true, - logo: true, - }, - }), - loadLogoOptions(), - db - .select({ - cartaoId: lancamentos.cartaoId, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) - ) - ) - .groupBy(lancamentos.cartaoId), - ]); + const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ + db.query.cartoes.findMany({ + orderBy: ( + card: typeof cartoes.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(card.name)], + where: and( + eq(cartoes.userId, userId), + not(ilike(cartoes.status, "inativo")), + ), + with: { + conta: { + columns: { + id: true, + name: true, + }, + }, + }, + }), + db.query.contas.findMany({ + orderBy: ( + account: typeof contas.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(account.name)], + where: eq(contas.userId, userId), + columns: { + id: true, + name: true, + logo: true, + }, + }), + loadLogoOptions(), + db + .select({ + cartaoId: lancamentos.cartaoId, + total: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)), + ), + ) + .groupBy(lancamentos.cartaoId), + ]); - const usageMap = new Map(); - usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { - if (!row.cartaoId) return; - usageMap.set(row.cartaoId, Number(row.total ?? 0)); - }); + const usageMap = new Map(); + usageRows.forEach( + (row: { cartaoId: string | null; total: number | null }) => { + if (!row.cartaoId) return; + usageMap.set(row.cartaoId, Number(row.total ?? 0)); + }, + ); - const cards = cardRows.map((card) => ({ - id: card.id, - name: card.name, - brand: card.brand, - status: card.status, - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note, - logo: card.logo, - limit: card.limit ? Number(card.limit) : null, - limitInUse: (() => { - const total = usageMap.get(card.id) ?? 0; - return total < 0 ? Math.abs(total) : 0; - })(), - limitAvailable: (() => { - if (!card.limit) { - return null; - } - const total = usageMap.get(card.id) ?? 0; - const inUse = total < 0 ? Math.abs(total) : 0; - return Math.max(Number(card.limit) - inUse, 0); - })(), - contaId: card.contaId, - contaName: card.conta?.name ?? "Conta não encontrada", - })); + const cards = cardRows.map((card) => ({ + id: card.id, + name: card.name, + brand: card.brand, + status: card.status, + closingDay: card.closingDay, + dueDay: card.dueDay, + note: card.note, + logo: card.logo, + limit: card.limit ? Number(card.limit) : null, + limitInUse: (() => { + const total = usageMap.get(card.id) ?? 0; + return total < 0 ? Math.abs(total) : 0; + })(), + limitAvailable: (() => { + if (!card.limit) { + return null; + } + const total = usageMap.get(card.id) ?? 0; + const inUse = total < 0 ? Math.abs(total) : 0; + return Math.max(Number(card.limit) - inUse, 0); + })(), + contaId: card.contaId, + contaName: card.conta?.name ?? "Conta não encontrada", + })); - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - logo: account.logo, - })); + const accounts = accountRows.map((account) => ({ + id: account.id, + name: account.name, + logo: account.logo, + })); - return { cards, accounts, logoOptions }; + return { cards, accounts, logoOptions }; } export async function fetchInativosForUser(userId: string): Promise<{ - cards: CardData[]; - accounts: AccountSimple[]; - logoOptions: LogoOption[]; + cards: CardData[]; + accounts: AccountSimple[]; + logoOptions: LogoOption[]; }> { - const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ - db.query.cartoes.findMany({ - orderBy: (card: typeof cartoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(card.name)], - where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")), - with: { - conta: { - columns: { - id: true, - name: true, - }, - }, - }, - }), - db.query.contas.findMany({ - orderBy: (account: typeof contas.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(account.name)], - where: eq(contas.userId, userId), - columns: { - id: true, - name: true, - logo: true, - }, - }), - loadLogoOptions(), - db - .select({ - cartaoId: lancamentos.cartaoId, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) - ) - ) - .groupBy(lancamentos.cartaoId), - ]); + const [cardRows, accountRows, logoOptions, usageRows] = await Promise.all([ + db.query.cartoes.findMany({ + orderBy: ( + card: typeof cartoes.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(card.name)], + where: and(eq(cartoes.userId, userId), ilike(cartoes.status, "inativo")), + with: { + conta: { + columns: { + id: true, + name: true, + }, + }, + }, + }), + db.query.contas.findMany({ + orderBy: ( + account: typeof contas.$inferSelect, + { desc }: { desc: (field: unknown) => unknown }, + ) => [desc(account.name)], + where: eq(contas.userId, userId), + columns: { + id: true, + name: true, + logo: true, + }, + }), + loadLogoOptions(), + db + .select({ + cartaoId: lancamentos.cartaoId, + total: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)), + ), + ) + .groupBy(lancamentos.cartaoId), + ]); - const usageMap = new Map(); - usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { - if (!row.cartaoId) return; - usageMap.set(row.cartaoId, Number(row.total ?? 0)); - }); + const usageMap = new Map(); + usageRows.forEach( + (row: { cartaoId: string | null; total: number | null }) => { + if (!row.cartaoId) return; + usageMap.set(row.cartaoId, Number(row.total ?? 0)); + }, + ); - const cards = cardRows.map((card) => ({ - id: card.id, - name: card.name, - brand: card.brand, - status: card.status, - closingDay: card.closingDay, - dueDay: card.dueDay, - note: card.note, - logo: card.logo, - limit: card.limit ? Number(card.limit) : null, - limitInUse: (() => { - const total = usageMap.get(card.id) ?? 0; - return total < 0 ? Math.abs(total) : 0; - })(), - limitAvailable: (() => { - if (!card.limit) { - return null; - } - const total = usageMap.get(card.id) ?? 0; - const inUse = total < 0 ? Math.abs(total) : 0; - return Math.max(Number(card.limit) - inUse, 0); - })(), - contaId: card.contaId, - contaName: card.conta?.name ?? "Conta não encontrada", - })); + const cards = cardRows.map((card) => ({ + id: card.id, + name: card.name, + brand: card.brand, + status: card.status, + closingDay: card.closingDay, + dueDay: card.dueDay, + note: card.note, + logo: card.logo, + limit: card.limit ? Number(card.limit) : null, + limitInUse: (() => { + const total = usageMap.get(card.id) ?? 0; + return total < 0 ? Math.abs(total) : 0; + })(), + limitAvailable: (() => { + if (!card.limit) { + return null; + } + const total = usageMap.get(card.id) ?? 0; + const inUse = total < 0 ? Math.abs(total) : 0; + return Math.max(Number(card.limit) - inUse, 0); + })(), + contaId: card.contaId, + contaName: card.conta?.name ?? "Conta não encontrada", + })); - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - logo: account.logo, - })); + const accounts = accountRows.map((account) => ({ + id: account.id, + name: account.name, + logo: account.logo, + })); - return { cards, accounts, logoOptions }; + return { cards, accounts, logoOptions }; } diff --git a/app/(dashboard)/cartoes/inativos/page.tsx b/app/(dashboard)/cartoes/inativos/page.tsx index da4d4e6..61a514d 100644 --- a/app/(dashboard)/cartoes/inativos/page.tsx +++ b/app/(dashboard)/cartoes/inativos/page.tsx @@ -3,17 +3,17 @@ import { getUserId } from "@/lib/auth/server"; import { fetchInativosForUser } from "../data"; export default async function InativosPage() { - const userId = await getUserId(); - const { cards, accounts, logoOptions } = await fetchInativosForUser(userId); + const userId = await getUserId(); + const { cards, accounts, logoOptions } = await fetchInativosForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/cartoes/layout.tsx b/app/(dashboard)/cartoes/layout.tsx index ef746d4..4b114f7 100644 --- a/app/(dashboard)/cartoes/layout.tsx +++ b/app/(dashboard)/cartoes/layout.tsx @@ -1,25 +1,25 @@ -import PageDescription from "@/components/page-description"; import { RiBankCard2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Cartões | Opensheets", + title: "Cartões | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Cartões" - subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites + return ( +
+ } + title="Cartões" + subtitle="Acompanhe todas os cartões do mês selecionado incluindo faturas, limites e transações previstas. Use o seletor abaixo para navegar pelos meses e visualizar as movimentações correspondentes." - /> - {children} -
- ); + /> + {children} +
+ ); } diff --git a/app/(dashboard)/cartoes/loading.tsx b/app/(dashboard)/cartoes/loading.tsx index bba11ef..1145fb1 100644 --- a/app/(dashboard)/cartoes/loading.tsx +++ b/app/(dashboard)/cartoes/loading.tsx @@ -1,33 +1,30 @@ import { Skeleton } from "@/components/ui/skeleton"; -/** - * Loading state para a página de cartões - */ export default function CartoesLoading() { - return ( -
-
- {/* Header */} -
- - -
+ return ( +
+
+ {/* Header */} +
+ + +
- {/* Grid de cartões */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- - - -
- ))} -
-
-
- ); + {/* Grid de cartões */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + + +
+ ))} +
+
+
+ ); } diff --git a/app/(dashboard)/cartoes/page.tsx b/app/(dashboard)/cartoes/page.tsx index 385570c..8ce99cf 100644 --- a/app/(dashboard)/cartoes/page.tsx +++ b/app/(dashboard)/cartoes/page.tsx @@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server"; import { fetchCardsForUser } from "./data"; export default async function Page() { - const userId = await getUserId(); - const { cards, accounts, logoOptions } = await fetchCardsForUser(userId); + const userId = await getUserId(); + const { cards, accounts, logoOptions } = await fetchCardsForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx index 0b48fd3..e897c5d 100644 --- a/app/(dashboard)/categorias/[categoryId]/page.tsx +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -1,99 +1,98 @@ +import { notFound } from "next/navigation"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { CategoryDetailHeader } from "@/components/categorias/category-detail-header"; import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; import MonthNavigation from "@/components/month-picker/month-navigation"; -import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details"; import { getUserId } from "@/lib/auth/server"; +import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details"; import { - buildOptionSets, - buildSluggedFilters, - fetchLancamentoFilterSources, + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, } from "@/lib/lancamentos/page-helpers"; import { displayPeriod, parsePeriodParam } from "@/lib/utils/period"; -import { notFound } from "next/navigation"; type PageSearchParams = Promise>; type PageProps = { - params: Promise<{ categoryId: string }>; - searchParams?: PageSearchParams; + params: Promise<{ categoryId: string }>; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? value[0] ?? null : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; export default async function Page({ params, searchParams }: PageProps) { - const { categoryId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; + const { categoryId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParam); + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [detail, filterSources, estabelecimentos] = - await Promise.all([ - fetchCategoryDetails(userId, categoryId, selectedPeriod), - fetchLancamentoFilterSources(userId), - getRecentEstablishmentsAction(), - ]); + const [detail, filterSources, estabelecimentos] = await Promise.all([ + fetchCategoryDetails(userId, categoryId, selectedPeriod), + fetchLancamentoFilterSources(userId), + getRecentEstablishmentsAction(), + ]); - if (!detail) { - notFound(); - } + if (!detail) { + notFound(); + } - const sluggedFilters = buildSluggedFilters(filterSources); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); + const sluggedFilters = buildSluggedFilters(filterSources); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); - const currentPeriodLabel = displayPeriod(detail.period); - const previousPeriodLabel = displayPeriod(detail.previousPeriod); + const currentPeriodLabel = displayPeriod(detail.period); + const previousPeriodLabel = displayPeriod(detail.previousPeriod); - return ( -
- - - -
- ); + return ( +
+ + + +
+ ); } diff --git a/app/(dashboard)/categorias/actions.ts b/app/(dashboard)/categorias/actions.ts index 39b33ef..1087dde 100644 --- a/app/(dashboard)/categorias/actions.ts +++ b/app/(dashboard)/categorias/actions.ts @@ -1,41 +1,41 @@ "use server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { categorias } from "@/db/schema"; import { - type ActionResult, - handleActionError, - revalidateForEntity, + type ActionResult, + handleActionError, + revalidateForEntity, } from "@/lib/actions/helpers"; import { getUser } from "@/lib/auth/server"; import { CATEGORY_TYPES } from "@/lib/categorias/constants"; import { db } from "@/lib/db"; import { uuidSchema } from "@/lib/schemas/common"; import { normalizeIconInput } from "@/lib/utils/string"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; const categoryBaseSchema = z.object({ - name: z - .string({ message: "Informe o nome da categoria." }) - .trim() - .min(1, "Informe o nome da categoria."), - type: z.enum(CATEGORY_TYPES, { - message: "Tipo de categoria inválido.", - }), - icon: z - .string() - .trim() - .max(100, "O ícone deve ter no máximo 100 caracteres.") - .nullish() - .transform((value) => normalizeIconInput(value)), + name: z + .string({ message: "Informe o nome da categoria." }) + .trim() + .min(1, "Informe o nome da categoria."), + type: z.enum(CATEGORY_TYPES, { + message: "Tipo de categoria inválido.", + }), + icon: z + .string() + .trim() + .max(100, "O ícone deve ter no máximo 100 caracteres.") + .nullish() + .transform((value) => normalizeIconInput(value)), }); const createCategorySchema = categoryBaseSchema; const updateCategorySchema = categoryBaseSchema.extend({ - id: uuidSchema("Categoria"), + id: uuidSchema("Categoria"), }); const deleteCategorySchema = z.object({ - id: uuidSchema("Categoria"), + id: uuidSchema("Categoria"), }); type CategoryCreateInput = z.infer; @@ -43,134 +43,134 @@ type CategoryUpdateInput = z.infer; type CategoryDeleteInput = z.infer; export async function createCategoryAction( - input: CategoryCreateInput + input: CategoryCreateInput, ): Promise { - try { - const user = await getUser(); - const data = createCategorySchema.parse(input); + try { + const user = await getUser(); + const data = createCategorySchema.parse(input); - await db.insert(categorias).values({ - name: data.name, - type: data.type, - icon: data.icon, - userId: user.id, - }); + await db.insert(categorias).values({ + name: data.name, + type: data.type, + icon: data.icon, + userId: user.id, + }); - revalidateForEntity("categorias"); + revalidateForEntity("categorias"); - return { success: true, message: "Categoria criada com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Categoria criada com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updateCategoryAction( - input: CategoryUpdateInput + input: CategoryUpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateCategorySchema.parse(input); + try { + const user = await getUser(); + const data = updateCategorySchema.parse(input); - // Buscar categoria antes de atualizar para verificar restrições - const categoria = await db.query.categorias.findFirst({ - columns: { id: true, name: true }, - where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), - }); + // Buscar categoria antes de atualizar para verificar restrições + const categoria = await db.query.categorias.findFirst({ + columns: { id: true, name: true }, + where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), + }); - if (!categoria) { - return { - success: false, - error: "Categoria não encontrada.", - }; - } + if (!categoria) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } - // Bloquear edição das categorias protegidas - const categoriasProtegidas = [ - "Transferência interna", - "Saldo inicial", - "Pagamentos", - ]; - if (categoriasProtegidas.includes(categoria.name)) { - return { - success: false, - error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`, - }; - } + // Bloquear edição das categorias protegidas + const categoriasProtegidas = [ + "Transferência interna", + "Saldo inicial", + "Pagamentos", + ]; + if (categoriasProtegidas.includes(categoria.name)) { + return { + success: false, + error: `A categoria '${categoria.name}' é protegida e não pode ser editada.`, + }; + } - const [updated] = await db - .update(categorias) - .set({ - name: data.name, - type: data.type, - icon: data.icon, - }) - .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) - .returning(); + const [updated] = await db + .update(categorias) + .set({ + name: data.name, + type: data.type, + icon: data.icon, + }) + .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) + .returning(); - if (!updated) { - return { - success: false, - error: "Categoria não encontrada.", - }; - } + if (!updated) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } - revalidateForEntity("categorias"); + revalidateForEntity("categorias"); - return { success: true, message: "Categoria atualizada com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Categoria atualizada com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deleteCategoryAction( - input: CategoryDeleteInput + input: CategoryDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteCategorySchema.parse(input); + try { + const user = await getUser(); + const data = deleteCategorySchema.parse(input); - // Buscar categoria antes de deletar para verificar restrições - const categoria = await db.query.categorias.findFirst({ - columns: { id: true, name: true }, - where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), - }); + // Buscar categoria antes de deletar para verificar restrições + const categoria = await db.query.categorias.findFirst({ + columns: { id: true, name: true }, + where: and(eq(categorias.id, data.id), eq(categorias.userId, user.id)), + }); - if (!categoria) { - return { - success: false, - error: "Categoria não encontrada.", - }; - } + if (!categoria) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } - // Bloquear remoção das categorias protegidas - const categoriasProtegidas = [ - "Transferência interna", - "Saldo inicial", - "Pagamentos", - ]; - if (categoriasProtegidas.includes(categoria.name)) { - return { - success: false, - error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`, - }; - } + // Bloquear remoção das categorias protegidas + const categoriasProtegidas = [ + "Transferência interna", + "Saldo inicial", + "Pagamentos", + ]; + if (categoriasProtegidas.includes(categoria.name)) { + return { + success: false, + error: `A categoria '${categoria.name}' é protegida e não pode ser removida.`, + }; + } - const [deleted] = await db - .delete(categorias) - .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) - .returning({ id: categorias.id }); + const [deleted] = await db + .delete(categorias) + .where(and(eq(categorias.id, data.id), eq(categorias.userId, user.id))) + .returning({ id: categorias.id }); - if (!deleted) { - return { - success: false, - error: "Categoria não encontrada.", - }; - } + if (!deleted) { + return { + success: false, + error: "Categoria não encontrada.", + }; + } - revalidateForEntity("categorias"); + revalidateForEntity("categorias"); - return { success: true, message: "Categoria removida com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Categoria removida com sucesso." }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/categorias/data.ts b/app/(dashboard)/categorias/data.ts index 5cf8930..beb0e8d 100644 --- a/app/(dashboard)/categorias/data.ts +++ b/app/(dashboard)/categorias/data.ts @@ -1,26 +1,26 @@ -import type { CategoryType } from "@/components/categorias/types"; -import { categorias, type Categoria } from "@/db/schema"; -import { db } from "@/lib/db"; import { eq } from "drizzle-orm"; +import type { CategoryType } from "@/components/categorias/types"; +import { type Categoria, categorias } from "@/db/schema"; +import { db } from "@/lib/db"; export type CategoryData = { - id: string; - name: string; - type: CategoryType; - icon: string | null; + id: string; + name: string; + type: CategoryType; + icon: string | null; }; export async function fetchCategoriesForUser( - userId: string + userId: string, ): Promise { - const categoryRows = await db.query.categorias.findMany({ - where: eq(categorias.userId, userId), - }); + const categoryRows = await db.query.categorias.findMany({ + where: eq(categorias.userId, userId), + }); - return categoryRows.map((category: Categoria) => ({ - id: category.id, - name: category.name, - type: category.type as CategoryType, - icon: category.icon, - })); + return categoryRows.map((category: Categoria) => ({ + id: category.id, + name: category.name, + type: category.type as CategoryType, + icon: category.icon, + })); } diff --git a/app/(dashboard)/categorias/historico/loading.tsx b/app/(dashboard)/categorias/historico/loading.tsx index fc2b027..ef68ec7 100644 --- a/app/(dashboard)/categorias/historico/loading.tsx +++ b/app/(dashboard)/categorias/historico/loading.tsx @@ -1,33 +1,33 @@ -import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { - return ( -
- - -
- {/* Selected categories and counter */} -
-
- - - -
-
- - -
-
+ return ( +
+ + +
+ {/* Selected categories and counter */} +
+
+ + + +
+
+ + +
+
- {/* Category selector button */} - -
+ {/* Category selector button */} + +
- {/* Chart */} - -
-
-
- ); + {/* Chart */} + + + + + ); } diff --git a/app/(dashboard)/categorias/historico/page.tsx b/app/(dashboard)/categorias/historico/page.tsx index 13652a9..4541dee 100644 --- a/app/(dashboard)/categorias/historico/page.tsx +++ b/app/(dashboard)/categorias/historico/page.tsx @@ -4,14 +4,14 @@ import { fetchCategoryHistory } from "@/lib/dashboard/categories/category-histor import { getCurrentPeriod } from "@/lib/utils/period"; export default async function HistoricoCategoriasPage() { - const user = await getUser(); - const currentPeriod = getCurrentPeriod(); + const user = await getUser(); + const currentPeriod = getCurrentPeriod(); - const data = await fetchCategoryHistory(user.id, currentPeriod); + const data = await fetchCategoryHistory(user.id, currentPeriod); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/categorias/layout.tsx b/app/(dashboard)/categorias/layout.tsx index 1b048a6..a6a1882 100644 --- a/app/(dashboard)/categorias/layout.tsx +++ b/app/(dashboard)/categorias/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiPriceTag3Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Categorias | Opensheets", + title: "Categorias | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Categorias" - subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário." - /> - {children} -
- ); + return ( +
+ } + title="Categorias" + subtitle="Gerencie suas categorias de despesas e receitas acompanhando o histórico de desempenho dos últimos 9 meses, permitindo ajustes financeiros precisos conforme necessário." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/categorias/loading.tsx b/app/(dashboard)/categorias/loading.tsx index e3bba35..47b6bef 100644 --- a/app/(dashboard)/categorias/loading.tsx +++ b/app/(dashboard)/categorias/loading.tsx @@ -5,57 +5,54 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: Header + Tabs + Grid de cards */ export default function CategoriasLoading() { - return ( -
-
- {/* Header */} -
- - -
+ return ( +
+
+ {/* Header */} +
+ + +
- {/* Tabs */} -
-
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
+ {/* Tabs */} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
- {/* Grid de cards de categorias */} -
- {Array.from({ length: 8 }).map((_, i) => ( -
- {/* Ícone + Nome */} -
- -
- - -
-
+ {/* Grid de cards de categorias */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ {/* Ícone + Nome */} +
+ +
+ + +
+
- {/* Descrição */} - {i % 3 === 0 && ( - - )} + {/* Descrição */} + {i % 3 === 0 && ( + + )} - {/* Botões de ação */} -
- - -
-
- ))} -
-
-
-
- ); + {/* Botões de ação */} +
+ + +
+
+ ))} + + + +
+ ); } diff --git a/app/(dashboard)/categorias/page.tsx b/app/(dashboard)/categorias/page.tsx index 4f7b060..dddb61b 100644 --- a/app/(dashboard)/categorias/page.tsx +++ b/app/(dashboard)/categorias/page.tsx @@ -3,12 +3,12 @@ import { getUserId } from "@/lib/auth/server"; import { fetchCategoriesForUser } from "./data"; export default async function Page() { - const userId = await getUserId(); - const categories = await fetchCategoriesForUser(userId); + const userId = await getUserId(); + const categories = await fetchCategoriesForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/contas/[contaId]/extrato/data.ts b/app/(dashboard)/contas/[contaId]/extrato/data.ts index 9967408..79700d2 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/data.ts +++ b/app/(dashboard)/contas/[contaId]/extrato/data.ts @@ -1,41 +1,41 @@ +import { and, desc, eq, lt, type SQL, sql } from "drizzle-orm"; import { contas, lancamentos, pagadores } from "@/db/schema"; import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { db } from "@/lib/db"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { and, eq, lt, sql } from "drizzle-orm"; export type AccountSummaryData = { - openingBalance: number; - currentBalance: number; - totalIncomes: number; - totalExpenses: number; + openingBalance: number; + currentBalance: number; + totalIncomes: number; + totalExpenses: number; }; export async function fetchAccountData(userId: string, contaId: string) { - const account = await db.query.contas.findFirst({ - columns: { - id: true, - name: true, - accountType: true, - status: true, - initialBalance: true, - logo: true, - note: true, - }, - where: and(eq(contas.id, contaId), eq(contas.userId, userId)), - }); + const account = await db.query.contas.findFirst({ + columns: { + id: true, + name: true, + accountType: true, + status: true, + initialBalance: true, + logo: true, + note: true, + }, + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); - return account; + return account; } export async function fetchAccountSummary( - userId: string, - contaId: string, - selectedPeriod: string + userId: string, + contaId: string, + selectedPeriod: string, ): Promise { - const [periodSummary] = await db - .select({ - netAmount: sql` + const [periodSummary] = await db + .select({ + netAmount: sql` coalesce( sum( case @@ -46,7 +46,7 @@ export async function fetchAccountSummary( 0 ) `, - incomes: sql` + incomes: sql` coalesce( sum( case @@ -58,7 +58,7 @@ export async function fetchAccountSummary( 0 ) `, - expenses: sql` + expenses: sql` coalesce( sum( case @@ -70,22 +70,22 @@ export async function fetchAccountSummary( 0 ) `, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.contaId, contaId), - eq(lancamentos.period, selectedPeriod), - eq(lancamentos.isSettled, true), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ); + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.contaId, contaId), + eq(lancamentos.period, selectedPeriod), + eq(lancamentos.isSettled, true), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ); - const [previousRow] = await db - .select({ - previousMovements: sql` + const [previousRow] = await db + .select({ + previousMovements: sql` coalesce( sum( case @@ -96,36 +96,56 @@ export async function fetchAccountSummary( 0 ) `, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.contaId, contaId), - lt(lancamentos.period, selectedPeriod), - eq(lancamentos.isSettled, true), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ); + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.contaId, contaId), + lt(lancamentos.period, selectedPeriod), + eq(lancamentos.isSettled, true), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ); - const account = await fetchAccountData(userId, contaId); - if (!account) { - throw new Error("Account not found"); - } + const account = await fetchAccountData(userId, contaId); + if (!account) { + throw new Error("Account not found"); + } - const initialBalance = Number(account.initialBalance ?? 0); - const previousMovements = Number(previousRow?.previousMovements ?? 0); - const openingBalance = initialBalance + previousMovements; - const netAmount = Number(periodSummary?.netAmount ?? 0); - const totalIncomes = Number(periodSummary?.incomes ?? 0); - const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); - const currentBalance = openingBalance + netAmount; + const initialBalance = Number(account.initialBalance ?? 0); + const previousMovements = Number(previousRow?.previousMovements ?? 0); + const openingBalance = initialBalance + previousMovements; + const netAmount = Number(periodSummary?.netAmount ?? 0); + const totalIncomes = Number(periodSummary?.incomes ?? 0); + const totalExpenses = Math.abs(Number(periodSummary?.expenses ?? 0)); + const currentBalance = openingBalance + netAmount; - return { - openingBalance, - currentBalance, - totalIncomes, - totalExpenses, - }; + return { + openingBalance, + currentBalance, + totalIncomes, + totalExpenses, + }; +} + +export async function fetchAccountLancamentos( + filters: SQL[], + settledOnly = true, +) { + const allFilters = settledOnly + ? [...filters, eq(lancamentos.isSettled, true)] + : filters; + + return db.query.lancamentos.findMany({ + where: and(...allFilters), + with: { + pagador: true, + conta: true, + cartao: true, + categoria: true, + }, + orderBy: desc(lancamentos.purchaseDate), + }); } diff --git a/app/(dashboard)/contas/[contaId]/extrato/loading.tsx b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx index c0825f0..efd7e3e 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/loading.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx @@ -1,7 +1,7 @@ import { - AccountStatementCardSkeleton, - FilterSkeleton, - TransactionsTableSkeleton, + AccountStatementCardSkeleton, + FilterSkeleton, + TransactionsTableSkeleton, } from "@/components/skeletons"; import { Skeleton } from "@/components/ui/skeleton"; @@ -10,29 +10,29 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos */ export default function ExtratoLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
- {/* Account Statement Card */} - + {/* Account Statement Card */} + - {/* Seção de lançamentos */} -
-
- {/* Header */} -
- -
+ {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ +
- {/* Filtros */} - + {/* Filtros */} + - {/* Tabela */} - -
-
-
- ); + {/* Tabela */} + +
+ +
+ ); } diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx index 7a63632..b0acf53 100644 --- a/app/(dashboard)/contas/[contaId]/extrato/page.tsx +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -1,3 +1,5 @@ +import { RiPencilLine } from "@remixicon/react"; +import { notFound } from "next/navigation"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; import { AccountDialog } from "@/components/contas/account-dialog"; import { AccountStatementCard } from "@/components/contas/account-statement-card"; @@ -5,178 +7,162 @@ import type { Account } from "@/components/contas/types"; import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { Button } from "@/components/ui/button"; -import { lancamentos } from "@/db/schema"; -import { db } from "@/lib/db"; import { getUserId } from "@/lib/auth/server"; import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type ResolvedSearchParams, + buildLancamentoWhere, + buildOptionSets, + buildSluggedFilters, + buildSlugMaps, + extractLancamentoSearchFilters, + fetchLancamentoFilterSources, + getSingleParam, + mapLancamentosData, + type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { loadLogoOptions } from "@/lib/logo/options"; import { parsePeriodParam } from "@/lib/utils/period"; -import { RiPencilLine } from "@remixicon/react"; -import { and, desc, eq } from "drizzle-orm"; -import { notFound } from "next/navigation"; -import { fetchAccountData, fetchAccountSummary } from "./data"; +import { + fetchAccountData, + fetchAccountLancamentos, + fetchAccountSummary, +} from "./data"; type PageSearchParams = Promise; type PageProps = { - params: Promise<{ contaId: string }>; - searchParams?: PageSearchParams; + params: Promise<{ contaId: string }>; + searchParams?: PageSearchParams; }; const capitalize = (value: string) => - value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; + value.length > 0 ? value[0]?.toUpperCase().concat(value.slice(1)) : value; export default async function Page({ params, searchParams }: PageProps) { - const { contaId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; + const { contaId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName, - year, - } = parsePeriodParam(periodoParamRaw); + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { + period: selectedPeriod, + monthName, + year, + } = parsePeriodParam(periodoParamRaw); - const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const account = await fetchAccountData(userId, contaId); + const account = await fetchAccountData(userId, contaId); - if (!account) { - notFound(); - } + if (!account) { + notFound(); + } - const [ - filterSources, - logoOptions, - accountSummary, - estabelecimentos, - ] = await Promise.all([ - fetchLancamentoFilterSources(userId), - loadLogoOptions(), - fetchAccountSummary(userId, contaId, selectedPeriod), - getRecentEstablishmentsAction(), - ]); - const sluggedFilters = buildSluggedFilters(filterSources); - const slugMaps = buildSlugMaps(sluggedFilters); + const [filterSources, logoOptions, accountSummary, estabelecimentos] = + await Promise.all([ + fetchLancamentoFilterSources(userId), + loadLogoOptions(), + fetchAccountSummary(userId, contaId, selectedPeriod), + getRecentEstablishmentsAction(), + ]); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); - const filters = buildLancamentoWhere({ - userId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - accountId: account.id, - }); + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + accountId: account.id, + }); - filters.push(eq(lancamentos.isSettled, true)); + const lancamentoRows = await fetchAccountLancamentos(filters); - const lancamentoRows = await db.query.lancamentos.findMany({ - where: and(...filters), - with: { - pagador: true, - conta: true, - cartao: true, - categoria: true, - }, - orderBy: desc(lancamentos.purchaseDate), - }); + const lancamentosData = mapLancamentosData(lancamentoRows); - const lancamentosData = mapLancamentosData(lancamentoRows); + const { openingBalance, currentBalance, totalIncomes, totalExpenses } = + accountSummary; - const { openingBalance, currentBalance, totalIncomes, totalExpenses } = - accountSummary; + const periodLabel = `${capitalize(monthName)} de ${year}`; - const periodLabel = `${capitalize(monthName)} de ${year}`; + const accountDialogData: Account = { + id: account.id, + name: account.name, + accountType: account.accountType, + status: account.status, + note: account.note, + logo: account.logo, + initialBalance: Number(account.initialBalance ?? 0), + balance: currentBalance, + }; - const accountDialogData: Account = { - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: currentBalance, - }; + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + limitContaId: account.id, + }); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - limitContaId: account.id, - }); + return ( +
+ - return ( -
- + + + + } + /> + } + /> - - - - } - /> - } - /> - -
- -
-
- ); +
+ +
+
+ ); } diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts index 3d86786..58dddb6 100644 --- a/app/(dashboard)/contas/actions.ts +++ b/app/(dashboard)/contas/actions.ts @@ -1,72 +1,75 @@ "use server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { categorias, contas, lancamentos, pagadores } from "@/db/schema"; import { - INITIAL_BALANCE_CATEGORY_NAME, - INITIAL_BALANCE_CONDITION, - INITIAL_BALANCE_NOTE, - INITIAL_BALANCE_PAYMENT_METHOD, - INITIAL_BALANCE_TRANSACTION_TYPE, + INITIAL_BALANCE_CATEGORY_NAME, + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, } from "@/lib/accounts/constants"; -import { type ActionResult, handleActionError } from "@/lib/actions/helpers"; -import { revalidateForEntity } from "@/lib/actions/helpers"; +import { + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import { + TRANSFER_CATEGORY_NAME, + TRANSFER_CONDITION, + TRANSFER_ESTABLISHMENT, + TRANSFER_PAYMENT_METHOD, +} from "@/lib/transferencias/constants"; import { formatDecimalForDbRequired } from "@/lib/utils/currency"; import { getTodayInfo } from "@/lib/utils/date"; import { normalizeFilePath } from "@/lib/utils/string"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { - TRANSFER_CATEGORY_NAME, - TRANSFER_CONDITION, - TRANSFER_ESTABLISHMENT, - TRANSFER_PAYMENT_METHOD, -} from "@/lib/transferencias/constants"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; const accountBaseSchema = z.object({ - name: z - .string({ message: "Informe o nome da conta." }) - .trim() - .min(1, "Informe o nome da conta."), - accountType: z - .string({ message: "Informe o tipo da conta." }) - .trim() - .min(1, "Informe o tipo da conta."), - status: z - .string({ message: "Informe o status da conta." }) - .trim() - .min(1, "Informe o status da conta."), - note: noteSchema, - logo: z - .string({ message: "Selecione um logo." }) - .trim() - .min(1, "Selecione um logo."), - initialBalance: z - .string() - .trim() - .transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) - .refine( - (value) => !Number.isNaN(Number.parseFloat(value)), - "Informe um saldo inicial válido." - ) - .transform((value) => Number.parseFloat(value)), - excludeFromBalance: z - .union([z.boolean(), z.string()]) - .transform((value) => value === true || value === "true"), - excludeInitialBalanceFromIncome: z - .union([z.boolean(), z.string()]) - .transform((value) => value === true || value === "true"), + name: z + .string({ message: "Informe o nome da conta." }) + .trim() + .min(1, "Informe o nome da conta."), + accountType: z + .string({ message: "Informe o tipo da conta." }) + .trim() + .min(1, "Informe o tipo da conta."), + status: z + .string({ message: "Informe o status da conta." }) + .trim() + .min(1, "Informe o status da conta."), + note: noteSchema, + logo: z + .string({ message: "Selecione um logo." }) + .trim() + .min(1, "Selecione um logo."), + initialBalance: z + .string() + .trim() + .transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) + .refine( + (value) => !Number.isNaN(Number.parseFloat(value)), + "Informe um saldo inicial válido.", + ) + .transform((value) => Number.parseFloat(value)), + excludeFromBalance: z + .union([z.boolean(), z.string()]) + .transform((value) => value === true || value === "true"), + excludeInitialBalanceFromIncome: z + .union([z.boolean(), z.string()]) + .transform((value) => value === true || value === "true"), }); const createAccountSchema = accountBaseSchema; const updateAccountSchema = accountBaseSchema.extend({ - id: uuidSchema("Conta"), + id: uuidSchema("Conta"), }); const deleteAccountSchema = z.object({ - id: uuidSchema("Conta"), + id: uuidSchema("Conta"), }); type AccountCreateInput = z.infer; @@ -74,315 +77,315 @@ type AccountUpdateInput = z.infer; type AccountDeleteInput = z.infer; export async function createAccountAction( - input: AccountCreateInput + input: AccountCreateInput, ): Promise { - try { - const user = await getUser(); - const data = createAccountSchema.parse(input); + try { + const user = await getUser(); + const data = createAccountSchema.parse(input); - const logoFile = normalizeFilePath(data.logo); + const logoFile = normalizeFilePath(data.logo); - const normalizedInitialBalance = Math.abs(data.initialBalance); - const hasInitialBalance = normalizedInitialBalance > 0; + const normalizedInitialBalance = Math.abs(data.initialBalance); + const hasInitialBalance = normalizedInitialBalance > 0; - await db.transaction(async (tx: typeof db) => { - const [createdAccount] = await tx - .insert(contas) - .values({ - name: data.name, - accountType: data.accountType, - status: data.status, - note: data.note ?? null, - logo: logoFile, - initialBalance: formatDecimalForDbRequired(data.initialBalance), - excludeFromBalance: data.excludeFromBalance, - excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, - userId: user.id, - }) - .returning({ id: contas.id, name: contas.name }); + await db.transaction(async (tx: typeof db) => { + const [createdAccount] = await tx + .insert(contas) + .values({ + name: data.name, + accountType: data.accountType, + status: data.status, + note: data.note ?? null, + logo: logoFile, + initialBalance: formatDecimalForDbRequired(data.initialBalance), + excludeFromBalance: data.excludeFromBalance, + excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, + userId: user.id, + }) + .returning({ id: contas.id, name: contas.name }); - if (!createdAccount) { - throw new Error("Não foi possível criar a conta."); - } + if (!createdAccount) { + throw new Error("Não foi possível criar a conta."); + } - if (!hasInitialBalance) { - return; - } + if (!hasInitialBalance) { + return; + } - const [category, adminPagador] = await Promise.all([ - tx.query.categorias.findFirst({ - columns: { id: true }, - where: and( - eq(categorias.userId, user.id), - eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME) - ), - }), - tx.query.pagadores.findFirst({ - columns: { id: true }, - where: and( - eq(pagadores.userId, user.id), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ), - }), - ]); + const [category, adminPagador] = await Promise.all([ + tx.query.categorias.findFirst({ + columns: { id: true }, + where: and( + eq(categorias.userId, user.id), + eq(categorias.name, INITIAL_BALANCE_CATEGORY_NAME), + ), + }), + tx.query.pagadores.findFirst({ + columns: { id: true }, + where: and( + eq(pagadores.userId, user.id), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + }), + ]); - if (!category) { - throw new Error( - 'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.' - ); - } + if (!category) { + throw new Error( + 'Categoria "Saldo inicial" não encontrada. Crie-a antes de definir um saldo inicial.', + ); + } - if (!adminPagador) { - throw new Error( - "Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial." - ); - } + if (!adminPagador) { + throw new Error( + "Pagador com papel administrador não encontrado. Crie um pagador admin antes de definir um saldo inicial.", + ); + } - const { date, period } = getTodayInfo(); + const { date, period } = getTodayInfo(); - await tx.insert(lancamentos).values({ - condition: INITIAL_BALANCE_CONDITION, - name: `Saldo inicial - ${createdAccount.name}`, - paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD, - note: INITIAL_BALANCE_NOTE, - amount: formatDecimalForDbRequired(normalizedInitialBalance), - purchaseDate: date, - transactionType: INITIAL_BALANCE_TRANSACTION_TYPE, - period, - isSettled: true, - userId: user.id, - contaId: createdAccount.id, - categoriaId: category.id, - pagadorId: adminPagador.id, - }); - }); + await tx.insert(lancamentos).values({ + condition: INITIAL_BALANCE_CONDITION, + name: `Saldo inicial - ${createdAccount.name}`, + paymentMethod: INITIAL_BALANCE_PAYMENT_METHOD, + note: INITIAL_BALANCE_NOTE, + amount: formatDecimalForDbRequired(normalizedInitialBalance), + purchaseDate: date, + transactionType: INITIAL_BALANCE_TRANSACTION_TYPE, + period, + isSettled: true, + userId: user.id, + contaId: createdAccount.id, + categoriaId: category.id, + pagadorId: adminPagador.id, + }); + }); - revalidateForEntity("contas"); + revalidateForEntity("contas"); - return { - success: true, - message: "Conta criada com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: "Conta criada com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } export async function updateAccountAction( - input: AccountUpdateInput + input: AccountUpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateAccountSchema.parse(input); + try { + const user = await getUser(); + const data = updateAccountSchema.parse(input); - const logoFile = normalizeFilePath(data.logo); + const logoFile = normalizeFilePath(data.logo); - const [updated] = await db - .update(contas) - .set({ - name: data.name, - accountType: data.accountType, - status: data.status, - note: data.note ?? null, - logo: logoFile, - initialBalance: formatDecimalForDbRequired(data.initialBalance), - excludeFromBalance: data.excludeFromBalance, - excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, - }) - .where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) - .returning(); + const [updated] = await db + .update(contas) + .set({ + name: data.name, + accountType: data.accountType, + status: data.status, + note: data.note ?? null, + logo: logoFile, + initialBalance: formatDecimalForDbRequired(data.initialBalance), + excludeFromBalance: data.excludeFromBalance, + excludeInitialBalanceFromIncome: data.excludeInitialBalanceFromIncome, + }) + .where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) + .returning(); - if (!updated) { - return { - success: false, - error: "Conta não encontrada.", - }; - } + if (!updated) { + return { + success: false, + error: "Conta não encontrada.", + }; + } - revalidateForEntity("contas"); + revalidateForEntity("contas"); - return { - success: true, - message: "Conta atualizada com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: "Conta atualizada com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } export async function deleteAccountAction( - input: AccountDeleteInput + input: AccountDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteAccountSchema.parse(input); + try { + const user = await getUser(); + const data = deleteAccountSchema.parse(input); - const [deleted] = await db - .delete(contas) - .where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) - .returning({ id: contas.id }); + const [deleted] = await db + .delete(contas) + .where(and(eq(contas.id, data.id), eq(contas.userId, user.id))) + .returning({ id: contas.id }); - if (!deleted) { - return { - success: false, - error: "Conta não encontrada.", - }; - } + if (!deleted) { + return { + success: false, + error: "Conta não encontrada.", + }; + } - revalidateForEntity("contas"); + revalidateForEntity("contas"); - return { - success: true, - message: "Conta removida com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: "Conta removida com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } // Transfer between accounts const transferSchema = z.object({ - fromAccountId: uuidSchema("Conta de origem"), - toAccountId: uuidSchema("Conta de destino"), - amount: z - .string() - .trim() - .transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) - .refine( - (value) => !Number.isNaN(Number.parseFloat(value)), - "Informe um valor válido." - ) - .transform((value) => Number.parseFloat(value)) - .refine((value) => value > 0, "O valor deve ser maior que zero."), - date: z.coerce.date({ message: "Informe uma data válida." }), - period: z - .string({ message: "Informe o período." }) - .trim() - .min(1, "Informe o período."), + fromAccountId: uuidSchema("Conta de origem"), + toAccountId: uuidSchema("Conta de destino"), + amount: z + .string() + .trim() + .transform((value) => (value.length === 0 ? "0" : value.replace(",", "."))) + .refine( + (value) => !Number.isNaN(Number.parseFloat(value)), + "Informe um valor válido.", + ) + .transform((value) => Number.parseFloat(value)) + .refine((value) => value > 0, "O valor deve ser maior que zero."), + date: z.coerce.date({ message: "Informe uma data válida." }), + period: z + .string({ message: "Informe o período." }) + .trim() + .min(1, "Informe o período."), }); type TransferInput = z.infer; export async function transferBetweenAccountsAction( - input: TransferInput + input: TransferInput, ): Promise { - try { - const user = await getUser(); - const data = transferSchema.parse(input); + try { + const user = await getUser(); + const data = transferSchema.parse(input); - // Validate that accounts are different - if (data.fromAccountId === data.toAccountId) { - return { - success: false, - error: "A conta de origem e destino devem ser diferentes.", - }; - } + // Validate that accounts are different + if (data.fromAccountId === data.toAccountId) { + return { + success: false, + error: "A conta de origem e destino devem ser diferentes.", + }; + } - // Generate a unique transfer ID to link both transactions - const transferId = crypto.randomUUID(); + // Generate a unique transfer ID to link both transactions + const transferId = crypto.randomUUID(); - await db.transaction(async (tx: typeof db) => { - // Verify both accounts exist and belong to the user - const [fromAccount, toAccount] = await Promise.all([ - tx.query.contas.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(contas.id, data.fromAccountId), - eq(contas.userId, user.id) - ), - }), - tx.query.contas.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(contas.id, data.toAccountId), - eq(contas.userId, user.id) - ), - }), - ]); + await db.transaction(async (tx: typeof db) => { + // Verify both accounts exist and belong to the user + const [fromAccount, toAccount] = await Promise.all([ + tx.query.contas.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(contas.id, data.fromAccountId), + eq(contas.userId, user.id), + ), + }), + tx.query.contas.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(contas.id, data.toAccountId), + eq(contas.userId, user.id), + ), + }), + ]); - if (!fromAccount) { - throw new Error("Conta de origem não encontrada."); - } + if (!fromAccount) { + throw new Error("Conta de origem não encontrada."); + } - if (!toAccount) { - throw new Error("Conta de destino não encontrada."); - } + if (!toAccount) { + throw new Error("Conta de destino não encontrada."); + } - // Get the transfer category - const transferCategory = await tx.query.categorias.findFirst({ - columns: { id: true }, - where: and( - eq(categorias.userId, user.id), - eq(categorias.name, TRANSFER_CATEGORY_NAME) - ), - }); + // Get the transfer category + const transferCategory = await tx.query.categorias.findFirst({ + columns: { id: true }, + where: and( + eq(categorias.userId, user.id), + eq(categorias.name, TRANSFER_CATEGORY_NAME), + ), + }); - if (!transferCategory) { - throw new Error( - `Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.` - ); - } + if (!transferCategory) { + throw new Error( + `Categoria "${TRANSFER_CATEGORY_NAME}" não encontrada. Por favor, crie esta categoria antes de fazer transferências.`, + ); + } - // Get the admin payer - const adminPagador = await tx.query.pagadores.findFirst({ - columns: { id: true }, - where: and( - eq(pagadores.userId, user.id), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ), - }); + // Get the admin payer + const adminPagador = await tx.query.pagadores.findFirst({ + columns: { id: true }, + where: and( + eq(pagadores.userId, user.id), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + }); - if (!adminPagador) { - throw new Error( - "Pagador administrador não encontrado. Por favor, crie um pagador admin." - ); - } + if (!adminPagador) { + throw new Error( + "Pagador administrador não encontrado. Por favor, crie um pagador admin.", + ); + } - // Create outgoing transaction (transfer from source account) - await tx.insert(lancamentos).values({ - condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`, - paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência para ${toAccount.name}`, - amount: formatDecimalForDbRequired(-Math.abs(data.amount)), - purchaseDate: data.date, - transactionType: "Transferência", - period: data.period, - isSettled: true, - userId: user.id, - contaId: fromAccount.id, - categoriaId: transferCategory.id, - pagadorId: adminPagador.id, - transferId, - }); + // Create outgoing transaction (transfer from source account) + await tx.insert(lancamentos).values({ + condition: TRANSFER_CONDITION, + name: `${TRANSFER_ESTABLISHMENT} → ${toAccount.name}`, + paymentMethod: TRANSFER_PAYMENT_METHOD, + note: `Transferência para ${toAccount.name}`, + amount: formatDecimalForDbRequired(-Math.abs(data.amount)), + purchaseDate: data.date, + transactionType: "Transferência", + period: data.period, + isSettled: true, + userId: user.id, + contaId: fromAccount.id, + categoriaId: transferCategory.id, + pagadorId: adminPagador.id, + transferId, + }); - // Create incoming transaction (transfer to destination account) - await tx.insert(lancamentos).values({ - condition: TRANSFER_CONDITION, - name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`, - paymentMethod: TRANSFER_PAYMENT_METHOD, - note: `Transferência de ${fromAccount.name}`, - amount: formatDecimalForDbRequired(Math.abs(data.amount)), - purchaseDate: data.date, - transactionType: "Transferência", - period: data.period, - isSettled: true, - userId: user.id, - contaId: toAccount.id, - categoriaId: transferCategory.id, - pagadorId: adminPagador.id, - transferId, - }); - }); + // Create incoming transaction (transfer to destination account) + await tx.insert(lancamentos).values({ + condition: TRANSFER_CONDITION, + name: `${TRANSFER_ESTABLISHMENT} ← ${fromAccount.name}`, + paymentMethod: TRANSFER_PAYMENT_METHOD, + note: `Transferência de ${fromAccount.name}`, + amount: formatDecimalForDbRequired(Math.abs(data.amount)), + purchaseDate: data.date, + transactionType: "Transferência", + period: data.period, + isSettled: true, + userId: user.id, + contaId: toAccount.id, + categoriaId: transferCategory.id, + pagadorId: adminPagador.id, + transferId, + }); + }); - revalidateForEntity("contas"); - revalidateForEntity("lancamentos"); + revalidateForEntity("contas"); + revalidateForEntity("lancamentos"); - return { - success: true, - message: "Transferência registrada com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: "Transferência registrada com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/contas/data.ts b/app/(dashboard)/contas/data.ts index 47a2df4..81995da 100644 --- a/app/(dashboard)/contas/data.ts +++ b/app/(dashboard)/contas/data.ts @@ -1,39 +1,39 @@ +import { and, eq, ilike, not, sql } from "drizzle-orm"; import { contas, lancamentos, pagadores } from "@/db/schema"; import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { db } from "@/lib/db"; import { loadLogoOptions } from "@/lib/logo/options"; import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { and, eq, ilike, not, sql } from "drizzle-orm"; export type AccountData = { - id: string; - name: string; - accountType: string; - status: string; - note: string | null; - logo: string | null; - initialBalance: number; - balance: number; - excludeFromBalance: boolean; - excludeInitialBalanceFromIncome: boolean; + id: string; + name: string; + accountType: string; + status: string; + note: string | null; + logo: string | null; + initialBalance: number; + balance: number; + excludeFromBalance: boolean; + excludeInitialBalanceFromIncome: boolean; }; export async function fetchAccountsForUser( - userId: string + userId: string, ): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { - const [accountRows, logoOptions] = await Promise.all([ - db - .select({ - id: contas.id, - name: contas.name, - accountType: contas.accountType, - status: contas.status, - note: contas.note, - logo: contas.logo, - initialBalance: contas.initialBalance, - excludeFromBalance: contas.excludeFromBalance, - excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, - balanceMovements: sql` + const [accountRows, logoOptions] = await Promise.all([ + db + .select({ + id: contas.id, + name: contas.name, + accountType: contas.accountType, + status: contas.status, + note: contas.note, + logo: contas.logo, + initialBalance: contas.initialBalance, + excludeFromBalance: contas.excludeFromBalance, + excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, + balanceMovements: sql` coalesce( sum( case @@ -44,72 +44,72 @@ export async function fetchAccountsForUser( 0 ) `, - }) - .from(contas) - .leftJoin( - lancamentos, - and( - eq(lancamentos.contaId, contas.id), - eq(lancamentos.userId, userId), - eq(lancamentos.isSettled, true) - ) - ) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(contas.userId, userId), - not(ilike(contas.status, "inativa")), - sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})` - ) - ) - .groupBy( - contas.id, - contas.name, - contas.accountType, - contas.status, - contas.note, - contas.logo, - contas.initialBalance, - contas.excludeFromBalance, - contas.excludeInitialBalanceFromIncome - ), - loadLogoOptions(), - ]); + }) + .from(contas) + .leftJoin( + lancamentos, + and( + eq(lancamentos.contaId, contas.id), + eq(lancamentos.userId, userId), + eq(lancamentos.isSettled, true), + ), + ) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(contas.userId, userId), + not(ilike(contas.status, "inativa")), + sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`, + ), + ) + .groupBy( + contas.id, + contas.name, + contas.accountType, + contas.status, + contas.note, + contas.logo, + contas.initialBalance, + contas.excludeFromBalance, + contas.excludeInitialBalanceFromIncome, + ), + loadLogoOptions(), + ]); - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: - Number(account.initialBalance ?? 0) + - Number(account.balanceMovements ?? 0), - excludeFromBalance: account.excludeFromBalance, - excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, - })); + const accounts = accountRows.map((account) => ({ + id: account.id, + name: account.name, + accountType: account.accountType, + status: account.status, + note: account.note, + logo: account.logo, + initialBalance: Number(account.initialBalance ?? 0), + balance: + Number(account.initialBalance ?? 0) + + Number(account.balanceMovements ?? 0), + excludeFromBalance: account.excludeFromBalance, + excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, + })); - return { accounts, logoOptions }; + return { accounts, logoOptions }; } export async function fetchInativosForUser( - userId: string + userId: string, ): Promise<{ accounts: AccountData[]; logoOptions: LogoOption[] }> { - const [accountRows, logoOptions] = await Promise.all([ - db - .select({ - id: contas.id, - name: contas.name, - accountType: contas.accountType, - status: contas.status, - note: contas.note, - logo: contas.logo, - initialBalance: contas.initialBalance, - excludeFromBalance: contas.excludeFromBalance, - excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, - balanceMovements: sql` + const [accountRows, logoOptions] = await Promise.all([ + db + .select({ + id: contas.id, + name: contas.name, + accountType: contas.accountType, + status: contas.status, + note: contas.note, + logo: contas.logo, + initialBalance: contas.initialBalance, + excludeFromBalance: contas.excludeFromBalance, + excludeInitialBalanceFromIncome: contas.excludeInitialBalanceFromIncome, + balanceMovements: sql` coalesce( sum( case @@ -120,52 +120,52 @@ export async function fetchInativosForUser( 0 ) `, - }) - .from(contas) - .leftJoin( - lancamentos, - and( - eq(lancamentos.contaId, contas.id), - eq(lancamentos.userId, userId), - eq(lancamentos.isSettled, true) - ) - ) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(contas.userId, userId), - ilike(contas.status, "inativa"), - sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})` - ) - ) - .groupBy( - contas.id, - contas.name, - contas.accountType, - contas.status, - contas.note, - contas.logo, - contas.initialBalance, - contas.excludeFromBalance, - contas.excludeInitialBalanceFromIncome - ), - loadLogoOptions(), - ]); + }) + .from(contas) + .leftJoin( + lancamentos, + and( + eq(lancamentos.contaId, contas.id), + eq(lancamentos.userId, userId), + eq(lancamentos.isSettled, true), + ), + ) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(contas.userId, userId), + ilike(contas.status, "inativa"), + sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`, + ), + ) + .groupBy( + contas.id, + contas.name, + contas.accountType, + contas.status, + contas.note, + contas.logo, + contas.initialBalance, + contas.excludeFromBalance, + contas.excludeInitialBalanceFromIncome, + ), + loadLogoOptions(), + ]); - const accounts = accountRows.map((account) => ({ - id: account.id, - name: account.name, - accountType: account.accountType, - status: account.status, - note: account.note, - logo: account.logo, - initialBalance: Number(account.initialBalance ?? 0), - balance: - Number(account.initialBalance ?? 0) + - Number(account.balanceMovements ?? 0), - excludeFromBalance: account.excludeFromBalance, - excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, - })); + const accounts = accountRows.map((account) => ({ + id: account.id, + name: account.name, + accountType: account.accountType, + status: account.status, + note: account.note, + logo: account.logo, + initialBalance: Number(account.initialBalance ?? 0), + balance: + Number(account.initialBalance ?? 0) + + Number(account.balanceMovements ?? 0), + excludeFromBalance: account.excludeFromBalance, + excludeInitialBalanceFromIncome: account.excludeInitialBalanceFromIncome, + })); - return { accounts, logoOptions }; + return { accounts, logoOptions }; } diff --git a/app/(dashboard)/contas/inativos/page.tsx b/app/(dashboard)/contas/inativos/page.tsx index 5419f7e..8ac4753 100644 --- a/app/(dashboard)/contas/inativos/page.tsx +++ b/app/(dashboard)/contas/inativos/page.tsx @@ -3,12 +3,16 @@ import { getUserId } from "@/lib/auth/server"; import { fetchInativosForUser } from "../data"; export default async function InativosPage() { - const userId = await getUserId(); - const { accounts, logoOptions } = await fetchInativosForUser(userId); + const userId = await getUserId(); + const { accounts, logoOptions } = await fetchInativosForUser(userId); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/contas/layout.tsx b/app/(dashboard)/contas/layout.tsx index b0fe64b..98ae874 100644 --- a/app/(dashboard)/contas/layout.tsx +++ b/app/(dashboard)/contas/layout.tsx @@ -1,25 +1,25 @@ -import PageDescription from "@/components/page-description"; import { RiBankLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Contas | Opensheets", + title: "Contas | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Contas" - subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas, + return ( +
+ } + title="Contas" + subtitle="Acompanhe todas as contas do mês selecionado incluindo receitas, despesas e transações previstas. Use o seletor abaixo para navegar pelos meses e visualizar as movimentações correspondentes." - /> - {children} -
- ); + /> + {children} +
+ ); } diff --git a/app/(dashboard)/contas/loading.tsx b/app/(dashboard)/contas/loading.tsx index ad9573f..8ba431d 100644 --- a/app/(dashboard)/contas/loading.tsx +++ b/app/(dashboard)/contas/loading.tsx @@ -4,33 +4,33 @@ import { Skeleton } from "@/components/ui/skeleton"; * Loading state para a página de contas */ export default function ContasLoading() { - return ( -
-
- {/* Header */} -
- - -
+ return ( +
+
+ {/* Header */} +
+ + +
- {/* Grid de contas */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
- - -
- - -
- - -
-
- ))} -
-
-
- ); + {/* Grid de contas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} +
+
+
+ ); } diff --git a/app/(dashboard)/contas/page.tsx b/app/(dashboard)/contas/page.tsx index 034caaf..359395a 100644 --- a/app/(dashboard)/contas/page.tsx +++ b/app/(dashboard)/contas/page.tsx @@ -3,14 +3,12 @@ import { getUserId } from "@/lib/auth/server"; import { fetchAccountsForUser } from "./data"; export default async function Page() { - const userId = await getUserId(); - const now = new Date(); + const userId = await getUserId(); + const { accounts, logoOptions } = await fetchAccountsForUser(userId); - const { accounts, logoOptions } = await fetchAccountsForUser(userId); - - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/dashboard/analise-parcelas/layout.tsx b/app/(dashboard)/dashboard/analise-parcelas/layout.tsx index 7c4458f..7f6ef50 100644 --- a/app/(dashboard)/dashboard/analise-parcelas/layout.tsx +++ b/app/(dashboard)/dashboard/analise-parcelas/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiSecurePaymentLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Análise de Parcelas | Opensheets", + title: "Análise de Parcelas | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Análise de Parcelas" - subtitle="Quanto você gastaria pagando suas despesas parceladas à vista?" - /> - {children} -
- ); + return ( +
+ } + title="Análise de Parcelas" + subtitle="Quanto você gastaria pagando suas despesas parceladas à vista?" + /> + {children} +
+ ); } diff --git a/app/(dashboard)/dashboard/analise-parcelas/page.tsx b/app/(dashboard)/dashboard/analise-parcelas/page.tsx index 1c6caa9..51306d4 100644 --- a/app/(dashboard)/dashboard/analise-parcelas/page.tsx +++ b/app/(dashboard)/dashboard/analise-parcelas/page.tsx @@ -3,12 +3,12 @@ import { getUser } from "@/lib/auth/server"; import { fetchInstallmentAnalysis } from "@/lib/dashboard/expenses/installment-analysis"; export default async function Page() { - const user = await getUser(); - const data = await fetchInstallmentAnalysis(user.id); + const user = await getUser(); + const data = await fetchInstallmentAnalysis(user.id); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/dashboard/data.ts b/app/(dashboard)/dashboard/data.ts new file mode 100644 index 0000000..4467c49 --- /dev/null +++ b/app/(dashboard)/dashboard/data.ts @@ -0,0 +1,25 @@ +import { eq } from "drizzle-orm"; +import { db, schema } from "@/lib/db"; + +export interface UserDashboardPreferences { + disableMagnetlines: boolean; + dashboardWidgets: string | null; +} + +export async function fetchUserDashboardPreferences( + userId: string, +): Promise { + const result = await db + .select({ + disableMagnetlines: schema.userPreferences.disableMagnetlines, + dashboardWidgets: schema.userPreferences.dashboardWidgets, + }) + .from(schema.userPreferences) + .where(eq(schema.userPreferences.userId, userId)) + .limit(1); + + return { + disableMagnetlines: result[0]?.disableMagnetlines ?? false, + dashboardWidgets: result[0]?.dashboardWidgets ?? null, + }; +} diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx index f725355..5e6c313 100644 --- a/app/(dashboard)/dashboard/loading.tsx +++ b/app/(dashboard)/dashboard/loading.tsx @@ -5,13 +5,13 @@ import { DashboardGridSkeleton } from "@/components/skeletons"; * Usa skeleton fiel ao layout final para evitar layout shift */ export default function DashboardLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
- {/* Dashboard content skeleton */} - -
- ); + {/* Dashboard content skeleton */} + +
+ ); } diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index e7e6530..65da0cd 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -4,59 +4,50 @@ import { SectionCards } from "@/components/dashboard/section-cards"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUser } from "@/lib/auth/server"; import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data"; -import { db, schema } from "@/lib/db"; import { parsePeriodParam } from "@/lib/utils/period"; -import { eq } from "drizzle-orm"; +import { fetchUserDashboardPreferences } from "./data"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string, + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? (value[0] ?? null) : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; export default async function Page({ searchParams }: PageProps) { - const user = await getUser(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParam); + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const [data, preferencesResult] = await Promise.all([ - fetchDashboardData(user.id, selectedPeriod), - db - .select({ - disableMagnetlines: schema.userPreferences.disableMagnetlines, - dashboardWidgets: schema.userPreferences.dashboardWidgets, - }) - .from(schema.userPreferences) - .where(eq(schema.userPreferences.userId, user.id)) - .limit(1), - ]); + const [data, preferences] = await Promise.all([ + fetchDashboardData(user.id, selectedPeriod), + fetchUserDashboardPreferences(user.id), + ]); - const disableMagnetlines = preferencesResult[0]?.disableMagnetlines ?? false; - const dashboardWidgets = preferencesResult[0]?.dashboardWidgets ?? null; + const { disableMagnetlines, dashboardWidgets } = preferences; - return ( -
- - - - -
- ); + return ( +
+ + + + +
+ ); } diff --git a/app/(dashboard)/insights/actions.ts b/app/(dashboard)/insights/actions.ts index 1bb2b46..5a965f3 100644 --- a/app/(dashboard)/insights/actions.ts +++ b/app/(dashboard)/insights/actions.ts @@ -1,23 +1,5 @@ "use server"; -import { - cartoes, - categorias, - contas, - lancamentos, - orcamentos, - pagadores, - savedInsights, -} from "@/db/schema"; -import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; -import { - InsightsResponseSchema, - type InsightsResponse, -} from "@/lib/schemas/insights"; -import { getPreviousPeriod } from "@/lib/utils/period"; import { anthropic } from "@ai-sdk/anthropic"; import { google } from "@ai-sdk/google"; import { openai } from "@ai-sdk/openai"; @@ -25,563 +7,631 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { generateObject } from "ai"; import { getDay } from "date-fns"; import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; +import { + cartoes, + categorias, + contas, + lancamentos, + orcamentos, + pagadores, + savedInsights, +} from "@/db/schema"; +import { ACCOUNT_AUTO_INVOICE_NOTE_PREFIX } from "@/lib/accounts/constants"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants"; +import { + type InsightsResponse, + InsightsResponseSchema, +} from "@/lib/schemas/insights"; +import { getPreviousPeriod } from "@/lib/utils/period"; import { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data"; const TRANSFERENCIA = "Transferência"; type ActionResult = - | { success: true; data: T } - | { success: false; error: string }; + | { success: true; data: T } + | { success: false; error: string }; /** * Função auxiliar para converter valores numéricos */ const toNumber = (value: unknown): number => { - if (typeof value === "number") return value; - if (typeof value === "string") { - const parsed = Number(value); - return Number.isNaN(parsed) ? 0 : parsed; - } - return 0; + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; }; /** * Agrega dados financeiros do mês para análise */ async function aggregateMonthData(userId: string, period: string) { - const previousPeriod = getPreviousPeriod(period); - const twoMonthsAgo = getPreviousPeriod(previousPeriod); - const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo); + const previousPeriod = getPreviousPeriod(period); + const twoMonthsAgo = getPreviousPeriod(previousPeriod); + const threeMonthsAgo = getPreviousPeriod(twoMonthsAgo); - // Buscar métricas de receitas e despesas dos últimos 3 meses - const [currentPeriodRows, previousPeriodRows, twoMonthsAgoRows, threeMonthsAgoRows] = await Promise.all([ - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` - ) - ) - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, previousPeriod), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` - ) - ) - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, twoMonthsAgo), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` - ) - ) - ) - .groupBy(lancamentos.transactionType), - db - .select({ - transactionType: lancamentos.transactionType, - totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, threeMonthsAgo), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` - ) - ) - ) - .groupBy(lancamentos.transactionType), - ]); + // Buscar métricas de receitas e despesas dos últimos 3 meses + const [ + currentPeriodRows, + previousPeriodRows, + twoMonthsAgoRows, + threeMonthsAgoRows, + ] = await Promise.all([ + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, + ), + ), + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, previousPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, + ), + ), + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, twoMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, + ), + ), + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, threeMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, + ), + ), + ) + .groupBy(lancamentos.transactionType), + ]); - // Calcular totais dos últimos 3 meses - let currentIncome = 0; - let currentExpense = 0; - let previousIncome = 0; - let previousExpense = 0; - let twoMonthsAgoIncome = 0; - let twoMonthsAgoExpense = 0; - let threeMonthsAgoIncome = 0; - let threeMonthsAgoExpense = 0; + // Calcular totais dos últimos 3 meses + let currentIncome = 0; + let currentExpense = 0; + let previousIncome = 0; + let previousExpense = 0; + let twoMonthsAgoIncome = 0; + let twoMonthsAgoExpense = 0; + let threeMonthsAgoIncome = 0; + let threeMonthsAgoExpense = 0; - for (const row of currentPeriodRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") currentIncome += amount; - else if (row.transactionType === "Despesa") currentExpense += amount; - } + for (const row of currentPeriodRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") currentIncome += amount; + else if (row.transactionType === "Despesa") currentExpense += amount; + } - for (const row of previousPeriodRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") previousIncome += amount; - else if (row.transactionType === "Despesa") previousExpense += amount; - } + for (const row of previousPeriodRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") previousIncome += amount; + else if (row.transactionType === "Despesa") previousExpense += amount; + } - for (const row of twoMonthsAgoRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") twoMonthsAgoIncome += amount; - else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount; - } + for (const row of twoMonthsAgoRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") twoMonthsAgoIncome += amount; + else if (row.transactionType === "Despesa") twoMonthsAgoExpense += amount; + } - for (const row of threeMonthsAgoRows) { - const amount = Math.abs(toNumber(row.totalAmount)); - if (row.transactionType === "Receita") threeMonthsAgoIncome += amount; - else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount; - } + for (const row of threeMonthsAgoRows) { + const amount = Math.abs(toNumber(row.totalAmount)); + if (row.transactionType === "Receita") threeMonthsAgoIncome += amount; + else if (row.transactionType === "Despesa") threeMonthsAgoExpense += amount; + } - // Buscar despesas por categoria (top 5) - const expensesByCategory = await db - .select({ - categoryName: categorias.name, - total: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - eq(categorias.type, "despesa"), - or( - isNull(lancamentos.note), - sql`${ - lancamentos.note - } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` - ) - ) - ) - .groupBy(categorias.name) - .orderBy(sql`sum(${lancamentos.amount}) ASC`) - .limit(5); + // Buscar despesas por categoria (top 5) + const expensesByCategory = await db + .select({ + categoryName: categorias.name, + total: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(categorias.type, "despesa"), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}`, + ), + ), + ) + .groupBy(categorias.name) + .orderBy(sql`sum(${lancamentos.amount}) ASC`) + .limit(5); - // Buscar orçamentos e uso - const budgetsData = await db - .select({ - categoryName: categorias.name, - budgetAmount: orcamentos.amount, - spent: sql`coalesce(sum(${lancamentos.amount}), 0)`, - }) - .from(orcamentos) - .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) - .leftJoin( - lancamentos, - and( - eq(lancamentos.categoriaId, categorias.id), - eq(lancamentos.period, period), - eq(lancamentos.userId, userId), - eq(lancamentos.transactionType, "Despesa") - ) - ) - .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) - .groupBy(categorias.name, orcamentos.amount); + // Buscar orçamentos e uso + const budgetsData = await db + .select({ + categoryName: categorias.name, + budgetAmount: orcamentos.amount, + spent: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(orcamentos) + .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) + .leftJoin( + lancamentos, + and( + eq(lancamentos.categoriaId, categorias.id), + eq(lancamentos.period, period), + eq(lancamentos.userId, userId), + eq(lancamentos.transactionType, "Despesa"), + ), + ) + .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) + .groupBy(categorias.name, orcamentos.amount); - // Buscar métricas de cartões - const cardsData = await db - .select({ - totalLimit: sql`coalesce(sum(${cartoes.limit}), 0)`, - cardCount: sql`count(*)`, - }) - .from(cartoes) - .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))); + // Buscar métricas de cartões + const cardsData = await db + .select({ + totalLimit: sql`coalesce(sum(${cartoes.limit}), 0)`, + cardCount: sql`count(*)`, + }) + .from(cartoes) + .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))); - // Buscar saldo total das contas - const accountsData = await db - .select({ - totalBalance: sql`coalesce(sum(${contas.initialBalance}), 0)`, - accountCount: sql`count(*)`, - }) - .from(contas) - .where( - and( - eq(contas.userId, userId), - eq(contas.status, "ativa"), - eq(contas.excludeFromBalance, false) - ) - ); + // Buscar saldo total das contas + const accountsData = await db + .select({ + totalBalance: sql`coalesce(sum(${contas.initialBalance}), 0)`, + accountCount: sql`count(*)`, + }) + .from(contas) + .where( + and( + eq(contas.userId, userId), + eq(contas.status, "ativa"), + eq(contas.excludeFromBalance, false), + ), + ); - // Calcular ticket médio das transações - const avgTicketData = await db - .select({ - avgAmount: sql`coalesce(avg(abs(${lancamentos.amount})), 0)`, - transactionCount: sql`count(*)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA) - ) - ); + // Calcular ticket médio das transações + const avgTicketData = await db + .select({ + avgAmount: sql`coalesce(avg(abs(${lancamentos.amount})), 0)`, + transactionCount: sql`count(*)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + ), + ); - // Buscar gastos por dia da semana - const dayOfWeekSpending = await db - .select({ - purchaseDate: lancamentos.purchaseDate, - amount: lancamentos.amount, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ); + // Buscar gastos por dia da semana + const dayOfWeekSpending = await db + .select({ + purchaseDate: lancamentos.purchaseDate, + amount: lancamentos.amount, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ); - // Agregar por dia da semana - const dayTotals = new Map(); - for (const row of dayOfWeekSpending) { - if (!row.purchaseDate) continue; - const dayOfWeek = getDay(new Date(row.purchaseDate)); - const current = dayTotals.get(dayOfWeek) ?? 0; - dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount))); - } + // Agregar por dia da semana + const dayTotals = new Map(); + for (const row of dayOfWeekSpending) { + if (!row.purchaseDate) continue; + const dayOfWeek = getDay(new Date(row.purchaseDate)); + const current = dayTotals.get(dayOfWeek) ?? 0; + dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount))); + } - // Buscar métodos de pagamento (agregado) - const paymentMethodsData = await db - .select({ - paymentMethod: lancamentos.paymentMethod, - total: sql`coalesce(sum(abs(${lancamentos.amount})), 0)`, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, period), - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN) - ) - ) - .groupBy(lancamentos.paymentMethod); + // Buscar métodos de pagamento (agregado) + const paymentMethodsData = await db + .select({ + paymentMethod: lancamentos.paymentMethod, + total: sql`coalesce(sum(abs(${lancamentos.amount})), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ), + ) + .groupBy(lancamentos.paymentMethod); - // Buscar transações dos últimos 3 meses para análise de recorrência - const last3MonthsTransactions = await db - .select({ - name: lancamentos.name, - amount: lancamentos.amount, - period: lancamentos.period, - condition: lancamentos.condition, - installmentCount: lancamentos.installmentCount, - currentInstallment: lancamentos.currentInstallment, - categoryName: categorias.name, - }) - .from(lancamentos) - .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - eq(lancamentos.userId, userId), - sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`, - eq(lancamentos.transactionType, "Despesa"), - eq(pagadores.role, PAGADOR_ROLE_ADMIN), - ne(lancamentos.transactionType, TRANSFERENCIA) - ) - ) - .orderBy(lancamentos.name); + // Buscar transações dos últimos 3 meses para análise de recorrência + const last3MonthsTransactions = await db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + period: lancamentos.period, + condition: lancamentos.condition, + installmentCount: lancamentos.installmentCount, + currentInstallment: lancamentos.currentInstallment, + categoryName: categorias.name, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + eq(lancamentos.userId, userId), + sql`${lancamentos.period} IN (${period}, ${previousPeriod}, ${twoMonthsAgo})`, + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + ), + ) + .orderBy(lancamentos.name); - // Análise de recorrência - const transactionsByName = new Map>(); + // Análise de recorrência + const transactionsByName = new Map< + string, + Array<{ period: string; amount: number }> + >(); - for (const tx of last3MonthsTransactions) { - const key = tx.name.toLowerCase().trim(); - if (!transactionsByName.has(key)) { - transactionsByName.set(key, []); - } - const transactions = transactionsByName.get(key); - if (transactions) { - transactions.push({ - period: tx.period, - amount: Math.abs(toNumber(tx.amount)), - }); - } - } + for (const tx of last3MonthsTransactions) { + const key = tx.name.toLowerCase().trim(); + if (!transactionsByName.has(key)) { + transactionsByName.set(key, []); + } + const transactions = transactionsByName.get(key); + if (transactions) { + transactions.push({ + period: tx.period, + amount: Math.abs(toNumber(tx.amount)), + }); + } + } - // Identificar gastos recorrentes (aparece em 2+ meses com valor similar) - const recurringExpenses: Array<{ name: string; avgAmount: number; frequency: number }> = []; - let totalRecurring = 0; + // Identificar gastos recorrentes (aparece em 2+ meses com valor similar) + const recurringExpenses: Array<{ + name: string; + avgAmount: number; + frequency: number; + }> = []; + let totalRecurring = 0; - for (const [name, occurrences] of transactionsByName.entries()) { - if (occurrences.length >= 2) { - const amounts = occurrences.map(o => o.amount); - const avgAmount = amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length; - const maxDiff = Math.max(...amounts) - Math.min(...amounts); + for (const [name, occurrences] of transactionsByName.entries()) { + if (occurrences.length >= 2) { + const amounts = occurrences.map((o) => o.amount); + const avgAmount = + amounts.reduce((sum, amt) => sum + amt, 0) / amounts.length; + const maxDiff = Math.max(...amounts) - Math.min(...amounts); - // Considerar recorrente se variação <= 20% da média - if (maxDiff <= avgAmount * 0.2) { - recurringExpenses.push({ - name, - avgAmount, - frequency: occurrences.length, - }); + // Considerar recorrente se variação <= 20% da média + if (maxDiff <= avgAmount * 0.2) { + recurringExpenses.push({ + name, + avgAmount, + frequency: occurrences.length, + }); - // Somar apenas os do mês atual - const currentMonthOccurrence = occurrences.find(o => o.period === period); - if (currentMonthOccurrence) { - totalRecurring += currentMonthOccurrence.amount; - } - } - } - } + // Somar apenas os do mês atual + const currentMonthOccurrence = occurrences.find( + (o) => o.period === period, + ); + if (currentMonthOccurrence) { + totalRecurring += currentMonthOccurrence.amount; + } + } + } + } - // Análise de gastos parcelados - const installmentTransactions = last3MonthsTransactions.filter( - tx => tx.condition === "Parcelado" && tx.installmentCount && tx.installmentCount > 1 - ); + // Análise de gastos parcelados + const installmentTransactions = last3MonthsTransactions.filter( + (tx) => + tx.condition === "Parcelado" && + tx.installmentCount && + tx.installmentCount > 1, + ); - const installmentData = installmentTransactions - .filter(tx => tx.period === period) - .map(tx => ({ - name: tx.name, - currentInstallment: tx.currentInstallment ?? 1, - totalInstallments: tx.installmentCount ?? 1, - amount: Math.abs(toNumber(tx.amount)), - category: tx.categoryName ?? "Outros", - })); + const installmentData = installmentTransactions + .filter((tx) => tx.period === period) + .map((tx) => ({ + name: tx.name, + currentInstallment: tx.currentInstallment ?? 1, + totalInstallments: tx.installmentCount ?? 1, + amount: Math.abs(toNumber(tx.amount)), + category: tx.categoryName ?? "Outros", + })); - const totalInstallmentAmount = installmentData.reduce((sum, tx) => sum + tx.amount, 0); - const futureCommitment = installmentData.reduce((sum, tx) => { - const remaining = (tx.totalInstallments - tx.currentInstallment); - return sum + (tx.amount * remaining); - }, 0); + const totalInstallmentAmount = installmentData.reduce( + (sum, tx) => sum + tx.amount, + 0, + ); + const futureCommitment = installmentData.reduce((sum, tx) => { + const remaining = tx.totalInstallments - tx.currentInstallment; + return sum + tx.amount * remaining; + }, 0); - // Montar dados agregados e anonimizados - const aggregatedData = { - month: period, - totalIncome: currentIncome, - totalExpense: currentExpense, - balance: currentIncome - currentExpense, + // Montar dados agregados e anonimizados + const aggregatedData = { + month: period, + totalIncome: currentIncome, + totalExpense: currentExpense, + balance: currentIncome - currentExpense, - // Tendência de 3 meses - threeMonthTrend: { - periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period], - incomes: [threeMonthsAgoIncome, twoMonthsAgoIncome, previousIncome, currentIncome], - expenses: [threeMonthsAgoExpense, twoMonthsAgoExpense, previousExpense, currentExpense], - avgIncome: (threeMonthsAgoIncome + twoMonthsAgoIncome + previousIncome + currentIncome) / 4, - avgExpense: (threeMonthsAgoExpense + twoMonthsAgoExpense + previousExpense + currentExpense) / 4, - trend: currentExpense > previousExpense && previousExpense > twoMonthsAgoExpense - ? "crescente" - : currentExpense < previousExpense && previousExpense < twoMonthsAgoExpense - ? "decrescente" - : "estável", - }, + // Tendência de 3 meses + threeMonthTrend: { + periods: [threeMonthsAgo, twoMonthsAgo, previousPeriod, period], + incomes: [ + threeMonthsAgoIncome, + twoMonthsAgoIncome, + previousIncome, + currentIncome, + ], + expenses: [ + threeMonthsAgoExpense, + twoMonthsAgoExpense, + previousExpense, + currentExpense, + ], + avgIncome: + (threeMonthsAgoIncome + + twoMonthsAgoIncome + + previousIncome + + currentIncome) / + 4, + avgExpense: + (threeMonthsAgoExpense + + twoMonthsAgoExpense + + previousExpense + + currentExpense) / + 4, + trend: + currentExpense > previousExpense && + previousExpense > twoMonthsAgoExpense + ? "crescente" + : currentExpense < previousExpense && + previousExpense < twoMonthsAgoExpense + ? "decrescente" + : "estável", + }, - previousMonthIncome: previousIncome, - previousMonthExpense: previousExpense, - monthOverMonthIncomeChange: - Math.abs(previousIncome) > 0.01 - ? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100 - : 0, - monthOverMonthExpenseChange: - Math.abs(previousExpense) > 0.01 - ? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100 - : 0, - savingsRate: - currentIncome > 0.01 - ? ((currentIncome - currentExpense) / currentIncome) * 100 - : 0, - topExpenseCategories: expensesByCategory.map( - (cat: { categoryName: string; total: unknown }) => ({ - category: cat.categoryName, - amount: Math.abs(toNumber(cat.total)), - percentageOfTotal: - currentExpense > 0 - ? (Math.abs(toNumber(cat.total)) / currentExpense) * 100 - : 0, - }) - ), - budgets: budgetsData.map( - (b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({ - category: b.categoryName, - budgetAmount: toNumber(b.budgetAmount), - spent: Math.abs(toNumber(b.spent)), - usagePercentage: - toNumber(b.budgetAmount) > 0 - ? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100 - : 0, - }) - ), - creditCards: { - totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0), - cardCount: toNumber(cardsData[0]?.cardCount ?? 0), - }, - accounts: { - totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0), - accountCount: toNumber(accountsData[0]?.accountCount ?? 0), - }, - avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0), - transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0), - dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({ - dayOfWeek: - ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A", - total, - })), - paymentMethodsBreakdown: paymentMethodsData.map( - (pm: { paymentMethod: string | null; total: unknown }) => ({ - method: pm.paymentMethod, - total: toNumber(pm.total), - percentage: - currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0, - }) - ), + previousMonthIncome: previousIncome, + previousMonthExpense: previousExpense, + monthOverMonthIncomeChange: + Math.abs(previousIncome) > 0.01 + ? ((currentIncome - previousIncome) / Math.abs(previousIncome)) * 100 + : 0, + monthOverMonthExpenseChange: + Math.abs(previousExpense) > 0.01 + ? ((currentExpense - previousExpense) / Math.abs(previousExpense)) * 100 + : 0, + savingsRate: + currentIncome > 0.01 + ? ((currentIncome - currentExpense) / currentIncome) * 100 + : 0, + topExpenseCategories: expensesByCategory.map( + (cat: { categoryName: string; total: unknown }) => ({ + category: cat.categoryName, + amount: Math.abs(toNumber(cat.total)), + percentageOfTotal: + currentExpense > 0 + ? (Math.abs(toNumber(cat.total)) / currentExpense) * 100 + : 0, + }), + ), + budgets: budgetsData.map( + (b: { categoryName: string; budgetAmount: unknown; spent: unknown }) => ({ + category: b.categoryName, + budgetAmount: toNumber(b.budgetAmount), + spent: Math.abs(toNumber(b.spent)), + usagePercentage: + toNumber(b.budgetAmount) > 0 + ? (Math.abs(toNumber(b.spent)) / toNumber(b.budgetAmount)) * 100 + : 0, + }), + ), + creditCards: { + totalLimit: toNumber(cardsData[0]?.totalLimit ?? 0), + cardCount: toNumber(cardsData[0]?.cardCount ?? 0), + }, + accounts: { + totalBalance: toNumber(accountsData[0]?.totalBalance ?? 0), + accountCount: toNumber(accountsData[0]?.accountCount ?? 0), + }, + avgTicket: toNumber(avgTicketData[0]?.avgAmount ?? 0), + transactionCount: toNumber(avgTicketData[0]?.transactionCount ?? 0), + dayOfWeekSpending: Array.from(dayTotals.entries()).map(([day, total]) => ({ + dayOfWeek: + ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"][day] ?? "N/A", + total, + })), + paymentMethodsBreakdown: paymentMethodsData.map( + (pm: { paymentMethod: string | null; total: unknown }) => ({ + method: pm.paymentMethod, + total: toNumber(pm.total), + percentage: + currentExpense > 0 ? (toNumber(pm.total) / currentExpense) * 100 : 0, + }), + ), - // Análise de recorrência - recurringExpenses: { - count: recurringExpenses.length, - total: totalRecurring, - percentageOfTotal: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, - topRecurring: recurringExpenses - .sort((a, b) => b.avgAmount - a.avgAmount) - .slice(0, 5) - .map(r => ({ - name: r.name, - avgAmount: r.avgAmount, - frequency: r.frequency, - })), - predictability: currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, - }, + // Análise de recorrência + recurringExpenses: { + count: recurringExpenses.length, + total: totalRecurring, + percentageOfTotal: + currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, + topRecurring: recurringExpenses + .sort((a, b) => b.avgAmount - a.avgAmount) + .slice(0, 5) + .map((r) => ({ + name: r.name, + avgAmount: r.avgAmount, + frequency: r.frequency, + })), + predictability: + currentExpense > 0 ? (totalRecurring / currentExpense) * 100 : 0, + }, - // Análise de parcelamentos - installments: { - currentMonthInstallments: installmentData.length, - totalInstallmentAmount, - percentageOfExpenses: currentExpense > 0 ? (totalInstallmentAmount / currentExpense) * 100 : 0, - futureCommitment, - topInstallments: installmentData - .sort((a, b) => b.amount - a.amount) - .slice(0, 5) - .map(i => ({ - name: i.name, - current: i.currentInstallment, - total: i.totalInstallments, - amount: i.amount, - category: i.category, - remaining: i.totalInstallments - i.currentInstallment, - })), - }, - }; + // Análise de parcelamentos + installments: { + currentMonthInstallments: installmentData.length, + totalInstallmentAmount, + percentageOfExpenses: + currentExpense > 0 + ? (totalInstallmentAmount / currentExpense) * 100 + : 0, + futureCommitment, + topInstallments: installmentData + .sort((a, b) => b.amount - a.amount) + .slice(0, 5) + .map((i) => ({ + name: i.name, + current: i.currentInstallment, + total: i.totalInstallments, + amount: i.amount, + category: i.category, + remaining: i.totalInstallments - i.currentInstallment, + })), + }, + }; - return aggregatedData; + return aggregatedData; } /** * Gera insights usando IA */ export async function generateInsightsAction( - period: string, - modelId: string + period: string, + modelId: string, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Validar modelo - verificar se existe na lista ou se é um modelo customizado - const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId); + // Validar modelo - verificar se existe na lista ou se é um modelo customizado + const selectedModel = AVAILABLE_MODELS.find((m) => m.id === modelId); - // Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido - if (!selectedModel && !modelId.includes("/")) { - return { - success: false, - error: "Modelo inválido.", - }; - } + // Se não encontrou na lista e não tem "/" (formato OpenRouter), é inválido + if (!selectedModel && !modelId.includes("/")) { + return { + success: false, + error: "Modelo inválido.", + }; + } - // Agregar dados - const aggregatedData = await aggregateMonthData(user.id, period); + // Agregar dados + const aggregatedData = await aggregateMonthData(user.id, period); - // Selecionar provider - let model; + // Selecionar provider + let model; - // Se o modelo tem "/" é OpenRouter (formato: provider/model) - if (modelId.includes("/")) { - const apiKey = process.env.OPENROUTER_API_KEY; - if (!apiKey) { - return { - success: false, - error: "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env", - }; - } + // Se o modelo tem "/" é OpenRouter (formato: provider/model) + if (modelId.includes("/")) { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + return { + success: false, + error: + "OPENROUTER_API_KEY não configurada. Adicione a chave no arquivo .env", + }; + } - const openrouter = createOpenRouter({ - apiKey, - }); - model = openrouter.chat(modelId); - } else if (selectedModel?.provider === "openai") { - model = openai(modelId); - } else if (selectedModel?.provider === "anthropic") { - model = anthropic(modelId); - } else if (selectedModel?.provider === "google") { - model = google(modelId); - } else { - return { - success: false, - error: "Provider de modelo não suportado.", - }; - } + const openrouter = createOpenRouter({ + apiKey, + }); + model = openrouter.chat(modelId); + } else if (selectedModel?.provider === "openai") { + model = openai(modelId); + } else if (selectedModel?.provider === "anthropic") { + model = anthropic(modelId); + } else if (selectedModel?.provider === "google") { + model = google(modelId); + } else { + return { + success: false, + error: "Provider de modelo não suportado.", + }; + } - // Chamar AI SDK - const result = await generateObject({ - model, - schema: InsightsResponseSchema, - system: INSIGHTS_SYSTEM_PROMPT, - prompt: `Analise os seguintes dados financeiros agregados do período ${period}. + // Chamar AI SDK + const result = await generateObject({ + model, + schema: InsightsResponseSchema, + system: INSIGHTS_SYSTEM_PROMPT, + prompt: `Analise os seguintes dados financeiros agregados do período ${period}. Dados agregados: ${JSON.stringify(aggregatedData, null, 2)} @@ -612,206 +662,216 @@ Organize suas observações nas 4 categorias especificadas no prompt do sistema: Cada item deve ser conciso, direto e acionável. Use os novos dados para dar contexto temporal e identificar padrões mais profundos. Responda APENAS com um JSON válido seguindo exatamente o schema especificado.`, - }); + }); - // Validar resposta - const validatedData = InsightsResponseSchema.parse(result.object); + // Validar resposta + const validatedData = InsightsResponseSchema.parse(result.object); - return { - success: true, - data: validatedData, - }; - } catch (error) { - console.error("Error generating insights:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erro ao gerar insights. Tente novamente.", - }; - } + return { + success: true, + data: validatedData, + }; + } catch (error) { + console.error("Error generating insights:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Erro ao gerar insights. Tente novamente.", + }; + } } /** * Salva insights gerados no banco de dados */ export async function saveInsightsAction( - period: string, - modelId: string, - data: InsightsResponse + period: string, + modelId: string, + data: InsightsResponse, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Verificar se já existe um insight salvo para este período - const existing = await db - .select() - .from(savedInsights) - .where( - and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) - ) - .limit(1); + // Verificar se já existe um insight salvo para este período + const existing = await db + .select() + .from(savedInsights) + .where( + and( + eq(savedInsights.userId, user.id), + eq(savedInsights.period, period), + ), + ) + .limit(1); - if (existing.length > 0) { - // Atualizar existente - const updated = await db - .update(savedInsights) - .set({ - modelId, - data: JSON.stringify(data), - updatedAt: new Date(), - }) - .where( - and( - eq(savedInsights.userId, user.id), - eq(savedInsights.period, period) - ) - ) - .returning({ id: savedInsights.id, createdAt: savedInsights.createdAt }); + if (existing.length > 0) { + // Atualizar existente + const updated = await db + .update(savedInsights) + .set({ + modelId, + data: JSON.stringify(data), + updatedAt: new Date(), + }) + .where( + and( + eq(savedInsights.userId, user.id), + eq(savedInsights.period, period), + ), + ) + .returning({ + id: savedInsights.id, + createdAt: savedInsights.createdAt, + }); - const updatedRecord = updated[0]; - if (!updatedRecord) { - return { - success: false, - error: "Falha ao atualizar a análise. Tente novamente.", - }; - } + const updatedRecord = updated[0]; + if (!updatedRecord) { + return { + success: false, + error: "Falha ao atualizar a análise. Tente novamente.", + }; + } - return { - success: true, - data: { - id: updatedRecord.id, - createdAt: updatedRecord.createdAt, - }, - }; - } + return { + success: true, + data: { + id: updatedRecord.id, + createdAt: updatedRecord.createdAt, + }, + }; + } - // Criar novo - const result = await db - .insert(savedInsights) - .values({ - userId: user.id, - period, - modelId, - data: JSON.stringify(data), - }) - .returning({ id: savedInsights.id, createdAt: savedInsights.createdAt }); + // Criar novo + const result = await db + .insert(savedInsights) + .values({ + userId: user.id, + period, + modelId, + data: JSON.stringify(data), + }) + .returning({ id: savedInsights.id, createdAt: savedInsights.createdAt }); - const insertedRecord = result[0]; - if (!insertedRecord) { - return { - success: false, - error: "Falha ao salvar a análise. Tente novamente.", - }; - } + const insertedRecord = result[0]; + if (!insertedRecord) { + return { + success: false, + error: "Falha ao salvar a análise. Tente novamente.", + }; + } - return { - success: true, - data: { - id: insertedRecord.id, - createdAt: insertedRecord.createdAt, - }, - }; - } catch (error) { - console.error("Error saving insights:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erro ao salvar análise. Tente novamente.", - }; - } + return { + success: true, + data: { + id: insertedRecord.id, + createdAt: insertedRecord.createdAt, + }, + }; + } catch (error) { + console.error("Error saving insights:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Erro ao salvar análise. Tente novamente.", + }; + } } /** * Carrega insights salvos do banco de dados */ -export async function loadSavedInsightsAction( - period: string -): Promise< - ActionResult<{ - insights: InsightsResponse; - modelId: string; - createdAt: Date; - } | null> +export async function loadSavedInsightsAction(period: string): Promise< + ActionResult<{ + insights: InsightsResponse; + modelId: string; + createdAt: Date; + } | null> > { - try { - const user = await getUser(); + try { + const user = await getUser(); - const result = await db - .select() - .from(savedInsights) - .where( - and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) - ) - .limit(1); + const result = await db + .select() + .from(savedInsights) + .where( + and( + eq(savedInsights.userId, user.id), + eq(savedInsights.period, period), + ), + ) + .limit(1); - if (result.length === 0) { - return { - success: true, - data: null, - }; - } + if (result.length === 0) { + return { + success: true, + data: null, + }; + } - const saved = result[0]; - if (!saved) { - return { - success: true, - data: null, - }; - } + const saved = result[0]; + if (!saved) { + return { + success: true, + data: null, + }; + } - const insights = InsightsResponseSchema.parse(JSON.parse(saved.data)); + const insights = InsightsResponseSchema.parse(JSON.parse(saved.data)); - return { - success: true, - data: { - insights, - modelId: saved.modelId, - createdAt: saved.createdAt, - }, - }; - } catch (error) { - console.error("Error loading saved insights:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erro ao carregar análise salva. Tente novamente.", - }; - } + return { + success: true, + data: { + insights, + modelId: saved.modelId, + createdAt: saved.createdAt, + }, + }; + } catch (error) { + console.error("Error loading saved insights:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Erro ao carregar análise salva. Tente novamente.", + }; + } } /** * Remove insights salvos do banco de dados */ export async function deleteSavedInsightsAction( - period: string + period: string, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - await db - .delete(savedInsights) - .where( - and(eq(savedInsights.userId, user.id), eq(savedInsights.period, period)) - ); + await db + .delete(savedInsights) + .where( + and( + eq(savedInsights.userId, user.id), + eq(savedInsights.period, period), + ), + ); - return { - success: true, - data: undefined, - }; - } catch (error) { - console.error("Error deleting saved insights:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Erro ao remover análise. Tente novamente.", - }; - } + return { + success: true, + data: undefined, + }; + } catch (error) { + console.error("Error deleting saved insights:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Erro ao remover análise. Tente novamente.", + }; + } } diff --git a/app/(dashboard)/insights/data.ts b/app/(dashboard)/insights/data.ts index aca0972..beacbca 100644 --- a/app/(dashboard)/insights/data.ts +++ b/app/(dashboard)/insights/data.ts @@ -7,84 +7,84 @@ export type AIProvider = "openai" | "anthropic" | "google" | "openrouter"; * Metadados dos providers */ export const PROVIDERS = { - openai: { - id: "openai" as const, - name: "ChatGPT", - icon: "RiOpenaiLine", - }, - anthropic: { - id: "anthropic" as const, - name: "Claude AI", - icon: "RiRobot2Line", - }, - google: { - id: "google" as const, - name: "Gemini", - icon: "RiGoogleLine", - }, - openrouter: { - id: "openrouter" as const, - name: "OpenRouter", - icon: "RiRouterLine", - }, + openai: { + id: "openai" as const, + name: "ChatGPT", + icon: "RiOpenaiLine", + }, + anthropic: { + id: "anthropic" as const, + name: "Claude AI", + icon: "RiRobot2Line", + }, + google: { + id: "google" as const, + name: "Gemini", + icon: "RiGoogleLine", + }, + openrouter: { + id: "openrouter" as const, + name: "OpenRouter", + icon: "RiRouterLine", + }, } as const; /** * Lista de modelos de IA disponíveis para análise de insights */ export const AVAILABLE_MODELS = [ - // OpenAI Models - GPT-5.2 Family (Latest) - { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const }, - { - id: "gpt-5.2-instant", - name: "GPT-5.2 Instant", - provider: "openai" as const, - }, - { - id: "gpt-5.2-thinking", - name: "GPT-5.2 Thinking", - provider: "openai" as const, - }, + // OpenAI Models - GPT-5.2 Family (Latest) + { id: "gpt-5.2", name: "GPT-5.2", provider: "openai" as const }, + { + id: "gpt-5.2-instant", + name: "GPT-5.2 Instant", + provider: "openai" as const, + }, + { + id: "gpt-5.2-thinking", + name: "GPT-5.2 Thinking", + provider: "openai" as const, + }, - // OpenAI Models - GPT-5 Family - { id: "gpt-5", name: "GPT-5", provider: "openai" as const }, - { id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const }, + // OpenAI Models - GPT-5 Family + { id: "gpt-5", name: "GPT-5", provider: "openai" as const }, + { id: "gpt-5-instant", name: "GPT-5 Instant", provider: "openai" as const }, - // Anthropic Models - Claude 4.5 - { - id: "claude-4.5-haiku", - name: "Claude 4.5 Haiku", - provider: "anthropic" as const, - }, - { - id: "claude-4.5-sonnet", - name: "Claude 4.5 Sonnet", - provider: "anthropic" as const, - }, - { - id: "claude-opus-4.1", - name: "Claude 4.1 Opus", - provider: "anthropic" as const, - }, + // Anthropic Models - Claude 4.5 + { + id: "claude-4.5-haiku", + name: "Claude 4.5 Haiku", + provider: "anthropic" as const, + }, + { + id: "claude-4.5-sonnet", + name: "Claude 4.5 Sonnet", + provider: "anthropic" as const, + }, + { + id: "claude-opus-4.1", + name: "Claude 4.1 Opus", + provider: "anthropic" as const, + }, - // Google Models - Gemini 3 (Latest) - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash", - provider: "google" as const, - }, - { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro", - provider: "google" as const, - }, + // Google Models - Gemini 3 (Latest) + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash", + provider: "google" as const, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro", + provider: "google" as const, + }, - // Google Models - Gemini 2.0 - { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash", - provider: "google" as const, - }, + // Google Models - Gemini 2.0 + { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + provider: "google" as const, + }, ] as const; export const DEFAULT_MODEL = "gpt-5.2"; diff --git a/app/(dashboard)/insights/layout.tsx b/app/(dashboard)/insights/layout.tsx index 832711c..4dea22d 100644 --- a/app/(dashboard)/insights/layout.tsx +++ b/app/(dashboard)/insights/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiSparklingLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Insights | Opensheets", + title: "Insights | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Insights" - subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria." - /> - {children} -
- ); + return ( +
+ } + title="Insights" + subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/insights/loading.tsx b/app/(dashboard)/insights/loading.tsx index 52aad2c..ce19e20 100644 --- a/app/(dashboard)/insights/loading.tsx +++ b/app/(dashboard)/insights/loading.tsx @@ -4,39 +4,36 @@ import { Skeleton } from "@/components/ui/skeleton"; * Loading state para a página de insights com IA */ export default function InsightsLoading() { - return ( -
-
- {/* Header */} -
- - -
+ return ( +
+
+ {/* Header */} +
+ + +
- {/* Grid de insights */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- - - -
- -
-
- - - -
-
- ))} -
-
-
- ); + {/* Grid de insights */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ + + +
+ +
+
+ + + +
+
+ ))} +
+
+
+ ); } diff --git a/app/(dashboard)/insights/page.tsx b/app/(dashboard)/insights/page.tsx index ddee7d5..424118a 100644 --- a/app/(dashboard)/insights/page.tsx +++ b/app/(dashboard)/insights/page.tsx @@ -5,27 +5,27 @@ import { parsePeriodParam } from "@/lib/utils/period"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? value[0] ?? null : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; export default async function Page({ searchParams }: PageProps) { - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParam); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts index 6e8f7b7..7a35609 100644 --- a/app/(dashboard)/lancamentos/actions.ts +++ b/app/(dashboard)/lancamentos/actions.ts @@ -1,102 +1,92 @@ "use server"; -import { contas, lancamentos, pagadores, categorias, cartoes } from "@/db/schema"; +import { randomUUID } from "node:crypto"; +import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; +import { z } from "zod"; import { - INITIAL_BALANCE_CONDITION, - INITIAL_BALANCE_NOTE, - INITIAL_BALANCE_PAYMENT_METHOD, - INITIAL_BALANCE_TRANSACTION_TYPE, + cartoes, + categorias, + contas, + lancamentos, + pagadores, +} from "@/db/schema"; +import { + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, } from "@/lib/accounts/constants"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import type { ActionResult } from "@/lib/actions/types"; -import { db } from "@/lib/db"; import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; import { - LANCAMENTO_CONDITIONS, - LANCAMENTO_PAYMENT_METHODS, - LANCAMENTO_TRANSACTION_TYPES, + LANCAMENTO_CONDITIONS, + LANCAMENTO_PAYMENT_METHODS, + LANCAMENTO_TRANSACTION_TYPES, } from "@/lib/lancamentos/constants"; import { - buildEntriesByPagador, - sendPagadorAutoEmails, + buildEntriesByPagador, + sendPagadorAutoEmails, } from "@/lib/pagadores/notifications"; import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { formatDecimalForDbRequired } from "@/lib/utils/currency"; -import { - getTodayDate, - getTodayDateString, - parseLocalDateString, -} from "@/lib/utils/date"; -import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; -import { z } from "zod"; +import { getTodayDate, parseLocalDateString } from "@/lib/utils/date"; // ============================================================================ // Authorization Validation Functions // ============================================================================ async function validatePagadorOwnership( - userId: string, - pagadorId: string | null | undefined + userId: string, + pagadorId: string | null | undefined, ): Promise { - if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar + if (!pagadorId) return true; // Se não tem pagadorId, não precisa validar - const pagador = await db.query.pagadores.findFirst({ - where: and( - eq(pagadores.id, pagadorId), - eq(pagadores.userId, userId) - ), - }); + const pagador = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, userId)), + }); - return !!pagador; + return !!pagador; } async function validateCategoriaOwnership( - userId: string, - categoriaId: string | null | undefined + userId: string, + categoriaId: string | null | undefined, ): Promise { - if (!categoriaId) return true; + if (!categoriaId) return true; - const categoria = await db.query.categorias.findFirst({ - where: and( - eq(categorias.id, categoriaId), - eq(categorias.userId, userId) - ), - }); + const categoria = await db.query.categorias.findFirst({ + where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), + }); - return !!categoria; + return !!categoria; } async function validateContaOwnership( - userId: string, - contaId: string | null | undefined + userId: string, + contaId: string | null | undefined, ): Promise { - if (!contaId) return true; + if (!contaId) return true; - const conta = await db.query.contas.findFirst({ - where: and( - eq(contas.id, contaId), - eq(contas.userId, userId) - ), - }); + const conta = await db.query.contas.findFirst({ + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); - return !!conta; + return !!conta; } async function validateCartaoOwnership( - userId: string, - cartaoId: string | null | undefined + userId: string, + cartaoId: string | null | undefined, ): Promise { - if (!cartaoId) return true; + if (!cartaoId) return true; - const cartao = await db.query.cartoes.findFirst({ - where: and( - eq(cartoes.id, cartaoId), - eq(cartoes.userId, userId) - ), - }); + const cartao = await db.query.cartoes.findFirst({ + where: and(eq(cartoes.id, cartaoId), eq(cartoes.userId, userId)), + }); - return !!cartao; + return !!cartao; } // ============================================================================ @@ -104,165 +94,165 @@ async function validateCartaoOwnership( // ============================================================================ const resolvePeriod = (purchaseDate: string, period?: string | null) => { - if (period && /^\d{4}-\d{2}$/.test(period)) { - return period; - } + if (period && /^\d{4}-\d{2}$/.test(period)) { + return period; + } - const date = parseLocalDateString(purchaseDate); - if (Number.isNaN(date.getTime())) { - throw new Error("Data da transação inválida."); - } + const date = parseLocalDateString(purchaseDate); + if (Number.isNaN(date.getTime())) { + throw new Error("Data da transação inválida."); + } - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; }; const baseFields = z.object({ - purchaseDate: z - .string({ message: "Informe a data da transação." }) - .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { - message: "Data da transação inválida.", - }), - period: z - .string() - .trim() - .regex(/^(\d{4})-(\d{2})$/, { - message: "Selecione um período válido.", - }) - .optional(), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - transactionType: z - .enum(LANCAMENTO_TRANSACTION_TYPES, { - message: "Selecione um tipo de transação válido.", - }) - .default(LANCAMENTO_TRANSACTION_TYPES[0]), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero."), - condition: z.enum(LANCAMENTO_CONDITIONS, { - message: "Selecione uma condição válida.", - }), - paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, { - message: "Selecione uma forma de pagamento válida.", - }), - pagadorId: uuidSchema("Pagador").nullable().optional(), - secondaryPagadorId: uuidSchema("Pagador secundário").optional(), - isSplit: z.boolean().optional().default(false), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - categoriaId: uuidSchema("Categoria").nullable().optional(), - note: noteSchema, - installmentCount: z.coerce - .number() - .int() - .min(1, "Selecione uma quantidade válida.") - .max(60, "Selecione uma quantidade válida.") - .optional(), - recurrenceCount: z.coerce - .number() - .int() - .min(1, "Selecione uma recorrência válida.") - .max(60, "Selecione uma recorrência válida.") - .optional(), - dueDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de vencimento válida.", - }) - .optional(), - boletoPaymentDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de pagamento válida.", - }) - .optional(), - isSettled: z.boolean().nullable().optional(), + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + transactionType: z + .enum(LANCAMENTO_TRANSACTION_TYPES, { + message: "Selecione um tipo de transação válido.", + }) + .default(LANCAMENTO_TRANSACTION_TYPES[0]), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + condition: z.enum(LANCAMENTO_CONDITIONS, { + message: "Selecione uma condição válida.", + }), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, { + message: "Selecione uma forma de pagamento válida.", + }), + pagadorId: uuidSchema("Pagador").nullable().optional(), + secondaryPagadorId: uuidSchema("Pagador secundário").optional(), + isSplit: z.boolean().optional().default(false), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + installmentCount: z.coerce + .number() + .int() + .min(1, "Selecione uma quantidade válida.") + .max(60, "Selecione uma quantidade válida.") + .optional(), + recurrenceCount: z.coerce + .number() + .int() + .min(1, "Selecione uma recorrência válida.") + .max(60, "Selecione uma recorrência válida.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional(), + isSettled: z.boolean().nullable().optional(), }); const refineLancamento = ( - data: z.infer & { id?: string }, - ctx: z.RefinementCtx + data: z.infer & { id?: string }, + ctx: z.RefinementCtx, ) => { - if (data.condition === "Parcelado") { - if (!data.installmentCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["installmentCount"], - message: "Informe a quantidade de parcelas.", - }); - } else if (data.installmentCount < 2) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["installmentCount"], - message: "Selecione pelo menos duas parcelas.", - }); - } - } + if (data.condition === "Parcelado") { + if (!data.installmentCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Informe a quantidade de parcelas.", + }); + } else if (data.installmentCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Selecione pelo menos duas parcelas.", + }); + } + } - if (data.condition === "Recorrente") { - if (!data.recurrenceCount) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["recurrenceCount"], - message: "Informe por quantos meses a recorrência acontecerá.", - }); - } else if (data.recurrenceCount < 2) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["recurrenceCount"], - message: "A recorrência deve ter ao menos dois meses.", - }); - } - } + if (data.condition === "Recorrente") { + if (!data.recurrenceCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "Informe por quantos meses a recorrência acontecerá.", + }); + } else if (data.recurrenceCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "A recorrência deve ter ao menos dois meses.", + }); + } + } - if (data.isSplit) { - if (!data.pagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["pagadorId"], - message: "Selecione o pagador principal para dividir o lançamento.", - }); - } + if (data.isSplit) { + if (!data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["pagadorId"], + message: "Selecione o pagador principal para dividir o lançamento.", + }); + } - if (!data.secondaryPagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["secondaryPagadorId"], - message: "Selecione o pagador secundário para dividir o lançamento.", - }); - } else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["secondaryPagadorId"], - message: "Escolha um pagador diferente para dividir o lançamento.", - }); - } - } + if (!data.secondaryPagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Selecione o pagador secundário para dividir o lançamento.", + }); + } else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Escolha um pagador diferente para dividir o lançamento.", + }); + } + } }; const createSchema = baseFields.superRefine(refineLancamento); const updateSchema = baseFields - .extend({ - id: uuidSchema("Lançamento"), - }) - .superRefine(refineLancamento); + .extend({ + id: uuidSchema("Lançamento"), + }) + .superRefine(refineLancamento); const deleteSchema = z.object({ - id: uuidSchema("Lançamento"), + id: uuidSchema("Lançamento"), }); const toggleSettlementSchema = z.object({ - id: uuidSchema("Lançamento"), - value: z.boolean({ - message: "Informe o status de pagamento.", - }), + id: uuidSchema("Lançamento"), + value: z.boolean({ + message: "Informe o status de pagamento.", + }), }); type BaseInput = z.infer; @@ -274,1316 +264,1352 @@ type ToggleSettlementInput = z.infer; const revalidate = () => revalidateForEntity("lancamentos"); const resolveUserLabel = (user: { - name?: string | null; - email?: string | null; + name?: string | null; + email?: string | null; }) => { - if (user?.name && user.name.trim().length > 0) { - return user.name; - } - if (user?.email && user.email.trim().length > 0) { - return user.email; - } - return "Opensheets"; + if (user?.name && user.name.trim().length > 0) { + return user.name; + } + if (user?.email && user.email.trim().length > 0) { + return user.email; + } + return "Opensheets"; }; type InitialCandidate = { - note: string | null; - transactionType: string | null; - condition: string | null; - paymentMethod: string | null; + note: string | null; + transactionType: string | null; + condition: string | null; + paymentMethod: string | null; }; const isInitialBalanceLancamento = (record?: InitialCandidate | null) => - !!record && - record.note === INITIAL_BALANCE_NOTE && - record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE && - record.condition === INITIAL_BALANCE_CONDITION && - record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD; + !!record && + record.note === INITIAL_BALANCE_NOTE && + record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE && + record.condition === INITIAL_BALANCE_CONDITION && + record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD; const centsToDecimalString = (value: number) => { - const decimal = value / 100; - const formatted = decimal.toFixed(2); - return Object.is(decimal, -0) ? "0.00" : formatted; + const decimal = value / 100; + const formatted = decimal.toFixed(2); + return Object.is(decimal, -0) ? "0.00" : formatted; }; const splitAmount = (totalCents: number, parts: number) => { - if (parts <= 0) { - return []; - } + if (parts <= 0) { + return []; + } - const base = Math.trunc(totalCents / parts); - const remainder = totalCents % parts; + const base = Math.trunc(totalCents / parts); + const remainder = totalCents % parts; - return Array.from( - { length: parts }, - (_, index) => base + (index < remainder ? 1 : 0) - ); + return Array.from( + { length: parts }, + (_, index) => base + (index < remainder ? 1 : 0), + ); }; const addMonthsToPeriod = (period: string, offset: number) => { - const [yearStr, monthStr] = period.split("-"); - const baseYear = Number(yearStr); - const baseMonth = Number(monthStr); + const [yearStr, monthStr] = period.split("-"); + const baseYear = Number(yearStr); + const baseMonth = Number(monthStr); - if (!baseYear || !baseMonth) { - throw new Error("Período inválido."); - } + if (!baseYear || !baseMonth) { + throw new Error("Período inválido."); + } - const date = new Date(baseYear, baseMonth - 1, 1); - date.setMonth(date.getMonth() + offset); + const date = new Date(baseYear, baseMonth - 1, 1); + date.setMonth(date.getMonth() + offset); - const nextYear = date.getFullYear(); - const nextMonth = String(date.getMonth() + 1).padStart(2, "0"); - return `${nextYear}-${nextMonth}`; + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, "0"); + return `${nextYear}-${nextMonth}`; }; const addMonthsToDate = (value: Date, offset: number) => { - const result = new Date(value); - const originalDay = result.getDate(); + const result = new Date(value); + const originalDay = result.getDate(); - result.setDate(1); - result.setMonth(result.getMonth() + offset); + result.setDate(1); + result.setMonth(result.getMonth() + offset); - const lastDay = new Date( - result.getFullYear(), - result.getMonth() + 1, - 0 - ).getDate(); + const lastDay = new Date( + result.getFullYear(), + result.getMonth() + 1, + 0, + ).getDate(); - result.setDate(Math.min(originalDay, lastDay)); - return result; + result.setDate(Math.min(originalDay, lastDay)); + return result; }; type Share = { - pagadorId: string | null; - amountCents: number; + pagadorId: string | null; + amountCents: number; }; const buildShares = ({ - totalCents, - pagadorId, - isSplit, - secondaryPagadorId, + totalCents, + pagadorId, + isSplit, + secondaryPagadorId, }: { - totalCents: number; - pagadorId: string | null; - isSplit: boolean; - secondaryPagadorId?: string; + totalCents: number; + pagadorId: string | null; + isSplit: boolean; + secondaryPagadorId?: string; }): Share[] => { - if (isSplit) { - if (!pagadorId || !secondaryPagadorId) { - throw new Error("Configuração de divisão inválida para o lançamento."); - } + if (isSplit) { + if (!pagadorId || !secondaryPagadorId) { + throw new Error("Configuração de divisão inválida para o lançamento."); + } - const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); - return [ - { pagadorId, amountCents: primaryAmount }, - { pagadorId: secondaryPagadorId, amountCents: secondaryAmount }, - ]; - } + const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); + return [ + { pagadorId, amountCents: primaryAmount }, + { pagadorId: secondaryPagadorId, amountCents: secondaryAmount }, + ]; + } - return [{ pagadorId, amountCents: totalCents }]; + return [{ pagadorId, amountCents: totalCents }]; }; type BuildLancamentoRecordsParams = { - data: BaseInput; - userId: string; - period: string; - purchaseDate: Date; - dueDate: Date | null; - boletoPaymentDate: Date | null; - shares: Share[]; - amountSign: 1 | -1; - shouldNullifySettled: boolean; - seriesId: string | null; + data: BaseInput; + userId: string; + period: string; + purchaseDate: Date; + dueDate: Date | null; + boletoPaymentDate: Date | null; + shares: Share[]; + amountSign: 1 | -1; + shouldNullifySettled: boolean; + seriesId: string | null; }; type LancamentoInsert = typeof lancamentos.$inferInsert; const buildLancamentoRecords = ({ - data, - userId, - period, - purchaseDate, - dueDate, - boletoPaymentDate, - shares, - amountSign, - shouldNullifySettled, - seriesId, + data, + userId, + period, + purchaseDate, + dueDate, + boletoPaymentDate, + shares, + amountSign, + shouldNullifySettled, + seriesId, }: BuildLancamentoRecordsParams): LancamentoInsert[] => { - const records: LancamentoInsert[] = []; + const records: LancamentoInsert[] = []; - const basePayload = { - name: data.name, - transactionType: data.transactionType, - condition: data.condition, - paymentMethod: data.paymentMethod, - note: data.note ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - categoriaId: data.categoriaId ?? null, - recurrenceCount: null as number | null, - installmentCount: null as number | null, - currentInstallment: null as number | null, - isDivided: data.isSplit ?? false, - userId, - seriesId, - }; + const basePayload = { + name: data.name, + transactionType: data.transactionType, + condition: data.condition, + paymentMethod: data.paymentMethod, + note: data.note ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + recurrenceCount: null as number | null, + installmentCount: null as number | null, + currentInstallment: null as number | null, + isDivided: data.isSplit ?? false, + userId, + seriesId, + }; - const resolveSettledValue = (cycleIndex: number) => { - if (shouldNullifySettled) { - return null; - } - const initialSettled = data.isSettled ?? false; - if (data.condition === "Parcelado" || data.condition === "Recorrente") { - return cycleIndex === 0 ? initialSettled : false; - } - return initialSettled; - }; + const resolveSettledValue = (cycleIndex: number) => { + if (shouldNullifySettled) { + return null; + } + const initialSettled = data.isSettled ?? false; + if (data.condition === "Parcelado" || data.condition === "Recorrente") { + return cycleIndex === 0 ? initialSettled : false; + } + return initialSettled; + }; - if (data.condition === "Parcelado") { - const installmentTotal = data.installmentCount ?? 0; - const amountsByShare = shares.map((share) => - splitAmount(share.amountCents, installmentTotal) - ); + if (data.condition === "Parcelado") { + const installmentTotal = data.installmentCount ?? 0; + const amountsByShare = shares.map((share) => + splitAmount(share.amountCents, installmentTotal), + ); - for ( - let installment = 0; - installment < installmentTotal; - installment += 1 - ) { - const installmentPeriod = addMonthsToPeriod(period, installment); - const installmentDueDate = dueDate - ? addMonthsToDate(dueDate, installment) - : null; + for ( + let installment = 0; + installment < installmentTotal; + installment += 1 + ) { + const installmentPeriod = addMonthsToPeriod(period, installment); + const installmentDueDate = dueDate + ? addMonthsToDate(dueDate, installment) + : null; - shares.forEach((share, shareIndex) => { - const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; - const settled = resolveSettledValue(installment); - records.push({ - ...basePayload, - amount: centsToDecimalString(amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate: purchaseDate, - period: installmentPeriod, - isSettled: settled, - installmentCount: installmentTotal, - currentInstallment: installment + 1, - recurrenceCount: null, - dueDate: installmentDueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled - ? boletoPaymentDate - : null, - }); - }); - } + shares.forEach((share, shareIndex) => { + const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; + const settled = resolveSettledValue(installment); + records.push({ + ...basePayload, + amount: centsToDecimalString(amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: purchaseDate, + period: installmentPeriod, + isSettled: settled, + installmentCount: installmentTotal, + currentInstallment: installment + 1, + recurrenceCount: null, + dueDate: installmentDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } - return records; - } + return records; + } - if (data.condition === "Recorrente") { - const recurrenceTotal = data.recurrenceCount ?? 0; + if (data.condition === "Recorrente") { + const recurrenceTotal = data.recurrenceCount ?? 0; - for (let index = 0; index < recurrenceTotal; index += 1) { - const recurrencePeriod = addMonthsToPeriod(period, index); - const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); - const recurrenceDueDate = dueDate - ? addMonthsToDate(dueDate, index) - : null; + for (let index = 0; index < recurrenceTotal; index += 1) { + const recurrencePeriod = addMonthsToPeriod(period, index); + const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); + const recurrenceDueDate = dueDate + ? addMonthsToDate(dueDate, index) + : null; - shares.forEach((share) => { - const settled = resolveSettledValue(index); - records.push({ - ...basePayload, - amount: centsToDecimalString(share.amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate: recurrencePurchaseDate, - period: recurrencePeriod, - isSettled: settled, - recurrenceCount: recurrenceTotal, - dueDate: recurrenceDueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled - ? boletoPaymentDate - : null, - }); - }); - } + shares.forEach((share) => { + const settled = resolveSettledValue(index); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: recurrencePurchaseDate, + period: recurrencePeriod, + isSettled: settled, + recurrenceCount: recurrenceTotal, + dueDate: recurrenceDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } - return records; - } + return records; + } - shares.forEach((share) => { - const settled = resolveSettledValue(0); - records.push({ - ...basePayload, - amount: centsToDecimalString(share.amountCents * amountSign), - pagadorId: share.pagadorId, - purchaseDate, - period, - isSettled: settled, - dueDate, - boletoPaymentDate: - data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, - }); - }); + shares.forEach((share) => { + const settled = resolveSettledValue(0); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate, + period, + isSettled: settled, + dueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, + }); + }); - return records; + return records; }; export async function createLancamentoAction( - input: CreateInput + input: CreateInput, ): Promise { - try { - const user = await getUser(); - const data = createSchema.parse(input); + try { + const user = await getUser(); + const data = createSchema.parse(input); - // Validar propriedade dos recursos referenciados - if (data.pagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.pagadorId); - if (!isValid) { - return { success: false, error: "Pagador não encontrado ou sem permissão." }; - } - } + // Validar propriedade dos recursos referenciados + if (data.pagadorId) { + const isValid = await validatePagadorOwnership(user.id, data.pagadorId); + if (!isValid) { + return { + success: false, + error: "Pagador não encontrado ou sem permissão.", + }; + } + } - if (data.secondaryPagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId); - if (!isValid) { - return { success: false, error: "Pagador secundário não encontrado ou sem permissão." }; - } - } + if (data.secondaryPagadorId) { + const isValid = await validatePagadorOwnership( + user.id, + data.secondaryPagadorId, + ); + if (!isValid) { + return { + success: false, + error: "Pagador secundário não encontrado ou sem permissão.", + }; + } + } - if (data.categoriaId) { - const isValid = await validateCategoriaOwnership(user.id, data.categoriaId); - if (!isValid) { - return { success: false, error: "Categoria não encontrada." }; - } - } + if (data.categoriaId) { + const isValid = await validateCategoriaOwnership( + user.id, + data.categoriaId, + ); + if (!isValid) { + return { success: false, error: "Categoria não encontrada." }; + } + } - if (data.contaId) { - const isValid = await validateContaOwnership(user.id, data.contaId); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } + if (data.contaId) { + const isValid = await validateContaOwnership(user.id, data.contaId); + if (!isValid) { + return { success: false, error: "Conta não encontrada." }; + } + } - if (data.cartaoId) { - const isValid = await validateCartaoOwnership(user.id, data.cartaoId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } + if (data.cartaoId) { + const isValid = await validateCartaoOwnership(user.id, data.cartaoId); + if (!isValid) { + return { success: false, error: "Cartão não encontrado." }; + } + } - const period = resolvePeriod(data.purchaseDate, data.period); - const purchaseDate = parseLocalDateString(data.purchaseDate); - const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null; - const shouldSetBoletoPaymentDate = - data.paymentMethod === "Boleto" && (data.isSettled ?? false); - const boletoPaymentDate = shouldSetBoletoPaymentDate - ? data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() - : null; + const period = resolvePeriod(data.purchaseDate, data.period); + const purchaseDate = parseLocalDateString(data.purchaseDate); + const dueDate = data.dueDate ? parseLocalDateString(data.dueDate) : null; + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && (data.isSettled ?? false); + const boletoPaymentDate = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? parseLocalDateString(data.boletoPaymentDate) + : getTodayDate() + : null; - const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; - const totalCents = Math.round(Math.abs(data.amount) * 100); - const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(data.amount) * 100); + const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; - const shares = buildShares({ - totalCents, - pagadorId: data.pagadorId ?? null, - isSplit: data.isSplit ?? false, - secondaryPagadorId: data.secondaryPagadorId, - }); + const shares = buildShares({ + totalCents, + pagadorId: data.pagadorId ?? null, + isSplit: data.isSplit ?? false, + secondaryPagadorId: data.secondaryPagadorId, + }); - const isSeriesLancamento = - data.condition === "Parcelado" || data.condition === "Recorrente"; - const seriesId = isSeriesLancamento ? randomUUID() : null; + const isSeriesLancamento = + data.condition === "Parcelado" || data.condition === "Recorrente"; + const seriesId = isSeriesLancamento ? randomUUID() : null; - const records = buildLancamentoRecords({ - data, - userId: user.id, - period, - purchaseDate, - dueDate, - shares, - amountSign, - shouldNullifySettled, - boletoPaymentDate, - seriesId, - }); + const records = buildLancamentoRecords({ + data, + userId: user.id, + period, + purchaseDate, + dueDate, + shares, + amountSign, + shouldNullifySettled, + boletoPaymentDate, + seriesId, + }); - if (!records.length) { - throw new Error("Não foi possível criar os lançamentos solicitados."); - } + if (!records.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } - await db.transaction(async (tx: typeof db) => { - await tx.insert(lancamentos).values(records); - }); + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(records); + }); - const notificationEntries = buildEntriesByPagador( - records.map((record) => ({ - pagadorId: record.pagadorId ?? null, - name: record.name ?? null, - amount: record.amount ?? null, - transactionType: record.transactionType ?? null, - paymentMethod: record.paymentMethod ?? null, - condition: record.condition ?? null, - purchaseDate: record.purchaseDate ?? null, - period: record.period ?? null, - note: record.note ?? null, - })) - ); + const notificationEntries = buildEntriesByPagador( + records.map((record) => ({ + pagadorId: record.pagadorId ?? null, + name: record.name ?? null, + amount: record.amount ?? null, + transactionType: record.transactionType ?? null, + paymentMethod: record.paymentMethod ?? null, + condition: record.condition ?? null, + purchaseDate: record.purchaseDate ?? null, + period: record.period ?? null, + note: record.note ?? null, + })), + ); - if (notificationEntries.size > 0) { - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "created", - entriesByPagador: notificationEntries, - }); - } + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } - revalidate(); + revalidate(); - return { success: true, message: "Lançamento criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Lançamento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updateLancamentoAction( - input: UpdateInput + input: UpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateSchema.parse(input); + try { + const user = await getUser(); + const data = updateSchema.parse(input); - // Validar propriedade dos recursos referenciados - if (data.pagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.pagadorId); - if (!isValid) { - return { success: false, error: "Pagador não encontrado ou sem permissão." }; - } - } + // Validar propriedade dos recursos referenciados + if (data.pagadorId) { + const isValid = await validatePagadorOwnership(user.id, data.pagadorId); + if (!isValid) { + return { + success: false, + error: "Pagador não encontrado ou sem permissão.", + }; + } + } - if (data.secondaryPagadorId) { - const isValid = await validatePagadorOwnership(user.id, data.secondaryPagadorId); - if (!isValid) { - return { success: false, error: "Pagador secundário não encontrado ou sem permissão." }; - } - } + if (data.secondaryPagadorId) { + const isValid = await validatePagadorOwnership( + user.id, + data.secondaryPagadorId, + ); + if (!isValid) { + return { + success: false, + error: "Pagador secundário não encontrado ou sem permissão.", + }; + } + } - if (data.categoriaId) { - const isValid = await validateCategoriaOwnership(user.id, data.categoriaId); - if (!isValid) { - return { success: false, error: "Categoria não encontrada." }; - } - } + if (data.categoriaId) { + const isValid = await validateCategoriaOwnership( + user.id, + data.categoriaId, + ); + if (!isValid) { + return { success: false, error: "Categoria não encontrada." }; + } + } - if (data.contaId) { - const isValid = await validateContaOwnership(user.id, data.contaId); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } + if (data.contaId) { + const isValid = await validateContaOwnership(user.id, data.contaId); + if (!isValid) { + return { success: false, error: "Conta não encontrada." }; + } + } - if (data.cartaoId) { - const isValid = await validateCartaoOwnership(user.id, data.cartaoId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } + if (data.cartaoId) { + const isValid = await validateCartaoOwnership(user.id, data.cartaoId); + if (!isValid) { + return { success: false, error: "Cartão não encontrado." }; + } + } - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - note: true, - transactionType: true, - condition: true, - paymentMethod: true, - contaId: true, - categoriaId: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - with: { - categoria: { - columns: { - name: true, - }, - }, - }, - }); + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + note: true, + transactionType: true, + condition: true, + paymentMethod: true, + contaId: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } - // Bloquear edição de lançamentos com categorias protegidas - // Nota: "Transferência interna" foi removida para permitir correção de valores - const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; - if ( - existing.categoria?.name && - categoriasProtegidasEdicao.includes(existing.categoria.name) - ) { - return { - success: false, - error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser editados.`, - }; - } + // Bloquear edição de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção de valores + const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasEdicao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser editados.`, + }; + } - const period = resolvePeriod(data.purchaseDate, data.period); - const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; - const amountCents = Math.round(Math.abs(data.amount) * 100); - const normalizedAmount = centsToDecimalString(amountCents * amountSign); - const normalizedSettled = - data.paymentMethod === "Cartão de crédito" - ? null - : data.isSettled ?? false; - const shouldSetBoletoPaymentDate = - data.paymentMethod === "Boleto" && Boolean(normalizedSettled); - const boletoPaymentDateValue = shouldSetBoletoPaymentDate - ? data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : getTodayDate() - : null; + const period = resolvePeriod(data.purchaseDate, data.period); + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + const normalizedAmount = centsToDecimalString(amountCents * amountSign); + const normalizedSettled = + data.paymentMethod === "Cartão de crédito" + ? null + : (data.isSettled ?? false); + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && Boolean(normalizedSettled); + const boletoPaymentDateValue = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? parseLocalDateString(data.boletoPaymentDate) + : getTodayDate() + : null; - await db - .update(lancamentos) - .set({ - name: data.name, - purchaseDate: parseLocalDateString(data.purchaseDate), - transactionType: data.transactionType, - amount: normalizedAmount, - condition: data.condition, - paymentMethod: data.paymentMethod, - pagadorId: data.pagadorId ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - categoriaId: data.categoriaId ?? null, - note: data.note ?? null, - isSettled: normalizedSettled, - installmentCount: data.installmentCount ?? null, - recurrenceCount: data.recurrenceCount ?? null, - dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null, - boletoPaymentDate: boletoPaymentDateValue, - period, - }) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + await db + .update(lancamentos) + .set({ + name: data.name, + purchaseDate: parseLocalDateString(data.purchaseDate), + transactionType: data.transactionType, + amount: normalizedAmount, + condition: data.condition, + paymentMethod: data.paymentMethod, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + isSettled: normalizedSettled, + installmentCount: data.installmentCount ?? null, + recurrenceCount: data.recurrenceCount ?? null, + dueDate: data.dueDate ? parseLocalDateString(data.dueDate) : null, + boletoPaymentDate: boletoPaymentDateValue, + period, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - if (isInitialBalanceLancamento(existing) && existing?.contaId) { - const updatedInitialBalance = formatDecimalForDbRequired( - Math.abs(data.amount ?? 0) - ); - await db - .update(contas) - .set({ initialBalance: updatedInitialBalance }) - .where( - and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)) - ); - } + if (isInitialBalanceLancamento(existing) && existing?.contaId) { + const updatedInitialBalance = formatDecimalForDbRequired( + Math.abs(data.amount ?? 0), + ); + await db + .update(contas) + .set({ initialBalance: updatedInitialBalance }) + .where( + and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)), + ); + } - revalidate(); + revalidate(); - return { success: true, message: "Lançamento atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Lançamento atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deleteLancamentoAction( - input: DeleteInput + input: DeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteSchema.parse(input); + try { + const user = await getUser(); + const data = deleteSchema.parse(input); - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - pagadorId: true, - amount: true, - transactionType: true, - paymentMethod: true, - condition: true, - purchaseDate: true, - period: true, - note: true, - categoriaId: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - with: { - categoria: { - columns: { - name: true, - }, - }, - }, - }); + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } - // Bloquear remoção de lançamentos com categorias protegidas - // Nota: "Transferência interna" foi removida para permitir correção/exclusão - const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; - if ( - existing.categoria?.name && - categoriasProtegidasRemocao.includes(existing.categoria.name) - ) { - return { - success: false, - error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`, - }; - } + // Bloquear remoção de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção/exclusão + const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasRemocao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`, + }; + } - await db - .delete(lancamentos) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + await db + .delete(lancamentos) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - if (existing.pagadorId) { - const notificationEntries = buildEntriesByPagador([ - { - pagadorId: existing.pagadorId, - name: existing.name ?? null, - amount: existing.amount ?? null, - transactionType: existing.transactionType ?? null, - paymentMethod: existing.paymentMethod ?? null, - condition: existing.condition ?? null, - purchaseDate: existing.purchaseDate ?? null, - period: existing.period ?? null, - note: existing.note ?? null, - }, - ]); + if (existing.pagadorId) { + const notificationEntries = buildEntriesByPagador([ + { + pagadorId: existing.pagadorId, + name: existing.name ?? null, + amount: existing.amount ?? null, + transactionType: existing.transactionType ?? null, + paymentMethod: existing.paymentMethod ?? null, + condition: existing.condition ?? null, + purchaseDate: existing.purchaseDate ?? null, + period: existing.period ?? null, + note: existing.note ?? null, + }, + ]); - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "deleted", - entriesByPagador: notificationEntries, - }); - } + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } - revalidate(); + revalidate(); - return { success: true, message: "Lançamento removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Lançamento removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function toggleLancamentoSettlementAction( - input: ToggleSettlementInput + input: ToggleSettlementInput, ): Promise { - try { - const user = await getUser(); - const data = toggleSettlementSchema.parse(input); + try { + const user = await getUser(); + const data = toggleSettlementSchema.parse(input); - const existing = await db.query.lancamentos.findFirst({ - columns: { id: true, paymentMethod: true }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); + const existing = await db.query.lancamentos.findFirst({ + columns: { id: true, paymentMethod: true }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } - if (existing.paymentMethod === "Cartão de crédito") { - return { - success: false, - error: "Pagamentos com cartão são conciliados automaticamente.", - }; - } + if (existing.paymentMethod === "Cartão de crédito") { + return { + success: false, + error: "Pagamentos com cartão são conciliados automaticamente.", + }; + } - const isBoleto = existing.paymentMethod === "Boleto"; - const boletoPaymentDate = isBoleto - ? data.value - ? getTodayDate() - : null - : null; + const isBoleto = existing.paymentMethod === "Boleto"; + const boletoPaymentDate = isBoleto + ? data.value + ? getTodayDate() + : null + : null; - await db - .update(lancamentos) - .set({ - isSettled: data.value, - boletoPaymentDate, - }) - .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + await db + .update(lancamentos) + .set({ + isSettled: data.value, + boletoPaymentDate, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); - revalidate(); + revalidate(); - return { - success: true, - message: data.value - ? "Lançamento marcado como pago." - : "Pagamento desfeito com sucesso.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: data.value + ? "Lançamento marcado como pago." + : "Pagamento desfeito com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } } const deleteBulkSchema = z.object({ - id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { - message: "Escopo de ação inválido.", - }), + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), }); type DeleteBulkInput = z.infer; export async function deleteLancamentoBulkAction( - input: DeleteBulkInput + input: DeleteBulkInput, ): Promise { - try { - const user = await getUser(); - const data = deleteBulkSchema.parse(input); + try { + const user = await getUser(); + const data = deleteBulkSchema.parse(input); - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - seriesId: true, - period: true, - condition: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } - if (!existing.seriesId) { - return { - success: false, - error: "Este lançamento não faz parte de uma série.", - }; - } + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } - if (data.scope === "current") { - await db - .delete(lancamentos) - .where( - and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)) - ); + if (data.scope === "current") { + await db + .delete(lancamentos) + .where( + and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + ); - revalidate(); - return { success: true, message: "Lançamento removido com sucesso." }; - } + revalidate(); + return { success: true, message: "Lançamento removido com sucesso." }; + } - if (data.scope === "future") { - await db - .delete(lancamentos) - .where( - and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - sql`${lancamentos.period} >= ${existing.period}` - ) - ); + if (data.scope === "future") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}`, + ), + ); - revalidate(); - return { - success: true, - message: "Lançamentos removidos com sucesso.", - }; - } + revalidate(); + return { + success: true, + message: "Lançamentos removidos com sucesso.", + }; + } - if (data.scope === "all") { - await db - .delete(lancamentos) - .where( - and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id) - ) - ); + if (data.scope === "all") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + ), + ); - revalidate(); - return { - success: true, - message: "Todos os lançamentos da série foram removidos.", - }; - } + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram removidos.", + }; + } - return { success: false, error: "Escopo de ação inválido." }; - } catch (error) { - return handleActionError(error); - } + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } } const updateBulkSchema = z.object({ - id: uuidSchema("Lançamento"), - scope: z.enum(["current", "future", "all"], { - message: "Escopo de ação inválido.", - }), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - categoriaId: uuidSchema("Categoria").nullable().optional(), - note: noteSchema, - pagadorId: uuidSchema("Pagador").nullable().optional(), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero.") - .optional(), - dueDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de vencimento válida.", - }) - .optional() - .nullable(), - boletoPaymentDate: z - .string() - .trim() - .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { - message: "Informe uma data de pagamento válida.", - }) - .optional() - .nullable(), + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + pagadorId: uuidSchema("Pagador").nullable().optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional() + .nullable(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional() + .nullable(), }); type UpdateBulkInput = z.infer; export async function updateLancamentoBulkAction( - input: UpdateBulkInput + input: UpdateBulkInput, ): Promise { - try { - const user = await getUser(); - const data = updateBulkSchema.parse(input); + try { + const user = await getUser(); + const data = updateBulkSchema.parse(input); - const existing = await db.query.lancamentos.findFirst({ - columns: { - id: true, - name: true, - seriesId: true, - period: true, - condition: true, - transactionType: true, - purchaseDate: true, - }, - where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), - }); + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + transactionType: true, + purchaseDate: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); - if (!existing) { - return { success: false, error: "Lançamento não encontrado." }; - } + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } - if (!existing.seriesId) { - return { - success: false, - error: "Este lançamento não faz parte de uma série.", - }; - } + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } - const baseUpdatePayload: Record = { - name: data.name, - categoriaId: data.categoriaId ?? null, - note: data.note ?? null, - pagadorId: data.pagadorId ?? null, - contaId: data.contaId ?? null, - cartaoId: data.cartaoId ?? null, - }; + const baseUpdatePayload: Record = { + name: data.name, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + }; - if (data.amount !== undefined) { - const amountSign: 1 | -1 = - existing.transactionType === "Despesa" ? -1 : 1; - const amountCents = Math.round(Math.abs(data.amount) * 100); - baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign); - } + if (data.amount !== undefined) { + const amountSign: 1 | -1 = + existing.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign); + } - const hasDueDateUpdate = data.dueDate !== undefined; - const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; + const hasDueDateUpdate = data.dueDate !== undefined; + const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; - const baseDueDate = - hasDueDateUpdate && data.dueDate - ? parseLocalDateString(data.dueDate) - : hasDueDateUpdate - ? null - : undefined; + const baseDueDate = + hasDueDateUpdate && data.dueDate + ? parseLocalDateString(data.dueDate) + : hasDueDateUpdate + ? null + : undefined; - const baseBoletoPaymentDate = - hasBoletoPaymentDateUpdate && data.boletoPaymentDate - ? parseLocalDateString(data.boletoPaymentDate) - : hasBoletoPaymentDateUpdate - ? null - : undefined; + const baseBoletoPaymentDate = + hasBoletoPaymentDateUpdate && data.boletoPaymentDate + ? parseLocalDateString(data.boletoPaymentDate) + : hasBoletoPaymentDateUpdate + ? null + : undefined; - const basePurchaseDate = existing.purchaseDate ?? null; + const basePurchaseDate = existing.purchaseDate ?? null; - const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { - if (!hasDueDateUpdate) { - return undefined; - } + const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { + if (!hasDueDateUpdate) { + return undefined; + } - if (!baseDueDate) { - return null; - } + if (!baseDueDate) { + return null; + } - if (!basePurchaseDate || !recordPurchaseDate) { - return baseDueDate; - } + if (!basePurchaseDate || !recordPurchaseDate) { + return baseDueDate; + } - const monthDiff = - (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * - 12 + - (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); + const monthDiff = + (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * + 12 + + (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); - return addMonthsToDate(baseDueDate, monthDiff); - }; + return addMonthsToDate(baseDueDate, monthDiff); + }; - const applyUpdates = async ( - records: Array<{ id: string; purchaseDate: Date | null }> - ) => { - if (records.length === 0) { - return; - } + const applyUpdates = async ( + records: Array<{ id: string; purchaseDate: Date | null }>, + ) => { + if (records.length === 0) { + return; + } - await db.transaction(async (tx: typeof db) => { - for (const record of records) { - const perRecordPayload: Record = { - ...baseUpdatePayload, - }; + await db.transaction(async (tx: typeof db) => { + for (const record of records) { + const perRecordPayload: Record = { + ...baseUpdatePayload, + }; - const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); - if (dueDateForRecord !== undefined) { - perRecordPayload.dueDate = dueDateForRecord; - } + const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); + if (dueDateForRecord !== undefined) { + perRecordPayload.dueDate = dueDateForRecord; + } - if (hasBoletoPaymentDateUpdate) { - perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; - } + if (hasBoletoPaymentDateUpdate) { + perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; + } - await tx - .update(lancamentos) - .set(perRecordPayload) - .where( - and( - eq(lancamentos.id, record.id), - eq(lancamentos.userId, user.id) - ) - ); - } - }); - }; + await tx + .update(lancamentos) + .set(perRecordPayload) + .where( + and( + eq(lancamentos.id, record.id), + eq(lancamentos.userId, user.id), + ), + ); + } + }); + }; - if (data.scope === "current") { - await applyUpdates([ - { - id: data.id, - purchaseDate: existing.purchaseDate ?? null, - }, - ]); + if (data.scope === "current") { + await applyUpdates([ + { + id: data.id, + purchaseDate: existing.purchaseDate ?? null, + }, + ]); - revalidate(); - return { success: true, message: "Lançamento atualizado com sucesso." }; - } + revalidate(); + return { success: true, message: "Lançamento atualizado com sucesso." }; + } - if (data.scope === "future") { - const futureLancamentos = await db.query.lancamentos.findMany({ - columns: { - id: true, - purchaseDate: true, - }, - where: and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id), - sql`${lancamentos.period} >= ${existing.period}` - ), - orderBy: asc(lancamentos.purchaseDate), - }); + if (data.scope === "future") { + const futureLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}`, + ), + orderBy: asc(lancamentos.purchaseDate), + }); - await applyUpdates( - futureLancamentos.map((item) => ({ - id: item.id, - purchaseDate: item.purchaseDate ?? null, - })) - ); + await applyUpdates( + futureLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })), + ); - revalidate(); - return { - success: true, - message: "Lançamentos atualizados com sucesso.", - }; - } + revalidate(); + return { + success: true, + message: "Lançamentos atualizados com sucesso.", + }; + } - if (data.scope === "all") { - const allLancamentos = await db.query.lancamentos.findMany({ - columns: { - id: true, - purchaseDate: true, - }, - where: and( - eq(lancamentos.seriesId, existing.seriesId), - eq(lancamentos.userId, user.id) - ), - orderBy: asc(lancamentos.purchaseDate), - }); + if (data.scope === "all") { + const allLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + ), + orderBy: asc(lancamentos.purchaseDate), + }); - await applyUpdates( - allLancamentos.map((item) => ({ - id: item.id, - purchaseDate: item.purchaseDate ?? null, - })) - ); + await applyUpdates( + allLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })), + ); - revalidate(); - return { - success: true, - message: "Todos os lançamentos da série foram atualizados.", - }; - } + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram atualizados.", + }; + } - return { success: false, error: "Escopo de ação inválido." }; - } catch (error) { - return handleActionError(error); - } + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } } // Mass Add Schema const massAddTransactionSchema = z.object({ - purchaseDate: z - .string({ message: "Informe a data da transação." }) - .trim() - .refine((value) => !Number.isNaN(new Date(value).getTime()), { - message: "Data da transação inválida.", - }), - name: z - .string({ message: "Informe o estabelecimento." }) - .trim() - .min(1, "Informe o estabelecimento."), - amount: z.coerce - .number({ message: "Informe o valor da transação." }) - .min(0, "Informe um valor maior ou igual a zero."), - categoriaId: uuidSchema("Categoria").nullable().optional(), - pagadorId: uuidSchema("Pagador").nullable().optional(), + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + categoriaId: uuidSchema("Categoria").nullable().optional(), + pagadorId: uuidSchema("Pagador").nullable().optional(), }); const massAddSchema = z.object({ - fixedFields: z.object({ - transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(), - paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(), - condition: z.enum(LANCAMENTO_CONDITIONS).optional(), - period: z - .string() - .trim() - .regex(/^(\d{4})-(\d{2})$/, { - message: "Selecione um período válido.", - }) - .optional(), - contaId: uuidSchema("Conta").nullable().optional(), - cartaoId: uuidSchema("Cartão").nullable().optional(), - }), - transactions: z - .array(massAddTransactionSchema) - .min(1, "Adicione pelo menos uma transação."), + fixedFields: z.object({ + transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(), + condition: z.enum(LANCAMENTO_CONDITIONS).optional(), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + }), + transactions: z + .array(massAddTransactionSchema) + .min(1, "Adicione pelo menos uma transação."), }); type MassAddInput = z.infer; export async function createMassLancamentosAction( - input: MassAddInput + input: MassAddInput, ): Promise { - try { - const user = await getUser(); - const data = massAddSchema.parse(input); + try { + const user = await getUser(); + const data = massAddSchema.parse(input); - // Validar campos fixos - if (data.fixedFields.contaId) { - const isValid = await validateContaOwnership(user.id, data.fixedFields.contaId); - if (!isValid) { - return { success: false, error: "Conta não encontrada." }; - } - } + // Validar campos fixos + if (data.fixedFields.contaId) { + const isValid = await validateContaOwnership( + user.id, + data.fixedFields.contaId, + ); + if (!isValid) { + return { success: false, error: "Conta não encontrada." }; + } + } - if (data.fixedFields.cartaoId) { - const isValid = await validateCartaoOwnership(user.id, data.fixedFields.cartaoId); - if (!isValid) { - return { success: false, error: "Cartão não encontrado." }; - } - } + if (data.fixedFields.cartaoId) { + const isValid = await validateCartaoOwnership( + user.id, + data.fixedFields.cartaoId, + ); + if (!isValid) { + return { success: false, error: "Cartão não encontrado." }; + } + } - // Validar cada transação individual - for (let i = 0; i < data.transactions.length; i++) { - const transaction = data.transactions[i]; + // Validar cada transação individual + for (let i = 0; i < data.transactions.length; i++) { + const transaction = data.transactions[i]; - if (transaction.pagadorId) { - const isValid = await validatePagadorOwnership(user.id, transaction.pagadorId); - if (!isValid) { - return { - success: false, - error: `Pagador não encontrado na transação ${i + 1}.` - }; - } - } + if (transaction.pagadorId) { + const isValid = await validatePagadorOwnership( + user.id, + transaction.pagadorId, + ); + if (!isValid) { + return { + success: false, + error: `Pagador não encontrado na transação ${i + 1}.`, + }; + } + } - if (transaction.categoriaId) { - const isValid = await validateCategoriaOwnership(user.id, transaction.categoriaId); - if (!isValid) { - return { - success: false, - error: `Categoria não encontrada na transação ${i + 1}.` - }; - } - } - } + if (transaction.categoriaId) { + const isValid = await validateCategoriaOwnership( + user.id, + transaction.categoriaId, + ); + if (!isValid) { + return { + success: false, + error: `Categoria não encontrada na transação ${i + 1}.`, + }; + } + } + } - // Default values for non-fixed fields - const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0]; - const defaultCondition = LANCAMENTO_CONDITIONS[0]; - const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0]; + // Default values for non-fixed fields + const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0]; + const defaultCondition = LANCAMENTO_CONDITIONS[0]; + const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0]; - const allRecords: LancamentoInsert[] = []; - const notificationData: Array<{ - pagadorId: string | null; - name: string | null; - amount: string | null; - transactionType: string | null; - paymentMethod: string | null; - condition: string | null; - purchaseDate: Date | null; - period: string | null; - note: string | null; - }> = []; + const allRecords: LancamentoInsert[] = []; + const notificationData: Array<{ + pagadorId: string | null; + name: string | null; + amount: string | null; + transactionType: string | null; + paymentMethod: string | null; + condition: string | null; + purchaseDate: Date | null; + period: string | null; + note: string | null; + }> = []; - // Process each transaction - for (const transaction of data.transactions) { - const transactionType = - data.fixedFields.transactionType ?? defaultTransactionType; - const condition = data.fixedFields.condition ?? defaultCondition; - const paymentMethod = - data.fixedFields.paymentMethod ?? defaultPaymentMethod; - const pagadorId = transaction.pagadorId ?? null; - const contaId = - paymentMethod === "Cartão de crédito" - ? null - : data.fixedFields.contaId ?? null; - const cartaoId = - paymentMethod === "Cartão de crédito" - ? data.fixedFields.cartaoId ?? null - : null; - const categoriaId = transaction.categoriaId ?? null; + // Process each transaction + for (const transaction of data.transactions) { + const transactionType = + data.fixedFields.transactionType ?? defaultTransactionType; + const condition = data.fixedFields.condition ?? defaultCondition; + const paymentMethod = + data.fixedFields.paymentMethod ?? defaultPaymentMethod; + const pagadorId = transaction.pagadorId ?? null; + const contaId = + paymentMethod === "Cartão de crédito" + ? null + : (data.fixedFields.contaId ?? null); + const cartaoId = + paymentMethod === "Cartão de crédito" + ? (data.fixedFields.cartaoId ?? null) + : null; + const categoriaId = transaction.categoriaId ?? null; - const period = - data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); - const purchaseDate = parseLocalDateString(transaction.purchaseDate); - const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; - const totalCents = Math.round(Math.abs(transaction.amount) * 100); - const amount = centsToDecimalString(totalCents * amountSign); - const isSettled = paymentMethod === "Cartão de crédito" ? null : false; + const period = + data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); + const purchaseDate = parseLocalDateString(transaction.purchaseDate); + const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(transaction.amount) * 100); + const amount = centsToDecimalString(totalCents * amountSign); + const isSettled = paymentMethod === "Cartão de crédito" ? null : false; - const record: LancamentoInsert = { - name: transaction.name, - purchaseDate, - period, - transactionType, - amount, - condition, - paymentMethod, - pagadorId, - contaId, - cartaoId, - categoriaId, - note: null, - installmentCount: null, - recurrenceCount: null, - currentInstallment: null, - isSettled, - isDivided: false, - dueDate: null, - boletoPaymentDate: null, - userId: user.id, - seriesId: null, - }; + const record: LancamentoInsert = { + name: transaction.name, + purchaseDate, + period, + transactionType, + amount, + condition, + paymentMethod, + pagadorId, + contaId, + cartaoId, + categoriaId, + note: null, + installmentCount: null, + recurrenceCount: null, + currentInstallment: null, + isSettled, + isDivided: false, + dueDate: null, + boletoPaymentDate: null, + userId: user.id, + seriesId: null, + }; - allRecords.push(record); + allRecords.push(record); - notificationData.push({ - pagadorId, - name: transaction.name, - amount, - transactionType, - paymentMethod, - condition, - purchaseDate, - period, - note: null, - }); - } + notificationData.push({ + pagadorId, + name: transaction.name, + amount, + transactionType, + paymentMethod, + condition, + purchaseDate, + period, + note: null, + }); + } - if (!allRecords.length) { - throw new Error("Não foi possível criar os lançamentos solicitados."); - } + if (!allRecords.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } - // Insert all records in a single transaction - await db.transaction(async (tx: typeof db) => { - await tx.insert(lancamentos).values(allRecords); - }); + // Insert all records in a single transaction + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(allRecords); + }); - // Send notifications - const notificationEntries = buildEntriesByPagador(notificationData); + // Send notifications + const notificationEntries = buildEntriesByPagador(notificationData); - if (notificationEntries.size > 0) { - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "created", - entriesByPagador: notificationEntries, - }); - } + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } - revalidate(); + revalidate(); - const count = allRecords.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento criado" : "lançamentos criados" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } + const count = allRecords.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento criado" : "lançamentos criados" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } } // Delete multiple lancamentos at once const deleteMultipleSchema = z.object({ - ids: z - .array(uuidSchema("Lançamento")) - .min(1, "Selecione pelo menos um lançamento."), + ids: z + .array(uuidSchema("Lançamento")) + .min(1, "Selecione pelo menos um lançamento."), }); type DeleteMultipleInput = z.infer; export async function deleteMultipleLancamentosAction( - input: DeleteMultipleInput + input: DeleteMultipleInput, ): Promise { - try { - const user = await getUser(); - const data = deleteMultipleSchema.parse(input); + try { + const user = await getUser(); + const data = deleteMultipleSchema.parse(input); - // Fetch all lancamentos to be deleted - const existing = await db.query.lancamentos.findMany({ - columns: { - id: true, - name: true, - pagadorId: true, - amount: true, - transactionType: true, - paymentMethod: true, - condition: true, - purchaseDate: true, - period: true, - note: true, - }, - where: and( - inArray(lancamentos.id, data.ids), - eq(lancamentos.userId, user.id) - ), - }); + // Fetch all lancamentos to be deleted + const existing = await db.query.lancamentos.findMany({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + }, + where: and( + inArray(lancamentos.id, data.ids), + eq(lancamentos.userId, user.id), + ), + }); - if (existing.length === 0) { - return { success: false, error: "Nenhum lançamento encontrado." }; - } + if (existing.length === 0) { + return { success: false, error: "Nenhum lançamento encontrado." }; + } - // Delete all lancamentos - await db - .delete(lancamentos) - .where( - and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)) - ); + // Delete all lancamentos + await db + .delete(lancamentos) + .where( + and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)), + ); - // Send notifications - const notificationData = existing - .filter( - ( - item - ): item is typeof item & { - pagadorId: NonNullable; - } => Boolean(item.pagadorId) - ) - .map((item) => ({ - pagadorId: item.pagadorId, - name: item.name ?? null, - amount: item.amount ?? null, - transactionType: item.transactionType ?? null, - paymentMethod: item.paymentMethod ?? null, - condition: item.condition ?? null, - purchaseDate: item.purchaseDate ?? null, - period: item.period ?? null, - note: item.note ?? null, - })); + // Send notifications + const notificationData = existing + .filter( + ( + item, + ): item is typeof item & { + pagadorId: NonNullable; + } => Boolean(item.pagadorId), + ) + .map((item) => ({ + pagadorId: item.pagadorId, + name: item.name ?? null, + amount: item.amount ?? null, + transactionType: item.transactionType ?? null, + paymentMethod: item.paymentMethod ?? null, + condition: item.condition ?? null, + purchaseDate: item.purchaseDate ?? null, + period: item.period ?? null, + note: item.note ?? null, + })); - if (notificationData.length > 0) { - const notificationEntries = buildEntriesByPagador(notificationData); + if (notificationData.length > 0) { + const notificationEntries = buildEntriesByPagador(notificationData); - await sendPagadorAutoEmails({ - userLabel: resolveUserLabel(user), - action: "deleted", - entriesByPagador: notificationEntries, - }); - } + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } - revalidate(); + revalidate(); - const count = existing.length; - return { - success: true, - message: `${count} ${ - count === 1 ? "lançamento removido" : "lançamentos removidos" - } com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } + const count = existing.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento removido" : "lançamentos removidos" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } } // Get unique establishment names from the last 3 months export async function getRecentEstablishmentsAction(): Promise { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Calculate date 3 months ago - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + // Calculate date 3 months ago + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - // Fetch establishment names from the last 3 months - const results = await db - .select({ name: lancamentos.name }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - gte(lancamentos.purchaseDate, threeMonthsAgo) - ) - ) - .orderBy(desc(lancamentos.purchaseDate)); + // Fetch establishment names from the last 3 months + const results = await db + .select({ name: lancamentos.name }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + gte(lancamentos.purchaseDate, threeMonthsAgo), + ), + ) + .orderBy(desc(lancamentos.purchaseDate)); - // Remove duplicates and filter empty names - const uniqueNames = Array.from( - new Set( - results - .map((r) => r.name) - .filter( - (name): name is string => - name != null && - name.trim().length > 0 && - !name.toLowerCase().startsWith("pagamento fatura") - ) - ) - ); + // Remove duplicates and filter empty names + const uniqueNames = Array.from( + new Set( + results + .map((r) => r.name) + .filter( + (name): name is string => + name != null && + name.trim().length > 0 && + !name.toLowerCase().startsWith("pagamento fatura"), + ), + ), + ); - // Return top 50 most recent unique establishments - return uniqueNames.slice(0, 100); - } catch (error) { - console.error("Error fetching recent establishments:", error); - return []; - } + // Return top 50 most recent unique establishments + return uniqueNames.slice(0, 100); + } catch (error) { + console.error("Error fetching recent establishments:", error); + return []; + } } diff --git a/app/(dashboard)/lancamentos/anticipation-actions.ts b/app/(dashboard)/lancamentos/anticipation-actions.ts index e9dc201..d70e571 100644 --- a/app/(dashboard)/lancamentos/anticipation-actions.ts +++ b/app/(dashboard)/lancamentos/anticipation-actions.ts @@ -1,336 +1,344 @@ "use server"; -import { - categorias, - installmentAnticipations, - lancamentos, - pagadores, - type InstallmentAnticipation, - type Lancamento, -} from "@/db/schema"; -import { handleActionError } from "@/lib/actions/helpers"; -import type { ActionResult } from "@/lib/actions/types"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { - generateAnticipationDescription, - generateAnticipationNote, -} from "@/lib/installments/anticipation-helpers"; -import type { - CancelAnticipationInput, - CreateAnticipationInput, - EligibleInstallment, - InstallmentAnticipationWithRelations, -} from "@/lib/installments/anticipation-types"; -import { uuidSchema } from "@/lib/schemas/common"; -import { formatDecimalForDbRequired } from "@/lib/utils/currency"; import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { + categorias, + installmentAnticipations, + lancamentos, + pagadores, +} from "@/db/schema"; +import { handleActionError } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { + generateAnticipationDescription, + generateAnticipationNote, +} from "@/lib/installments/anticipation-helpers"; +import type { + CancelAnticipationInput, + CreateAnticipationInput, + EligibleInstallment, + InstallmentAnticipationWithRelations, +} from "@/lib/installments/anticipation-types"; +import { uuidSchema } from "@/lib/schemas/common"; +import { formatDecimalForDbRequired } from "@/lib/utils/currency"; /** * Schema de validação para criar antecipação */ const createAnticipationSchema = z.object({ - seriesId: uuidSchema("Série"), - installmentIds: z - .array(uuidSchema("Parcela")) - .min(1, "Selecione pelo menos uma parcela para antecipar."), - anticipationPeriod: z - .string() - .trim() - .regex(/^(\d{4})-(\d{2})$/, { - message: "Selecione um período válido.", - }), - discount: z.coerce - .number() - .min(0, "Informe um desconto maior ou igual a zero.") - .optional() - .default(0), - pagadorId: uuidSchema("Pagador").optional(), - categoriaId: uuidSchema("Categoria").optional(), - note: z.string().trim().optional(), + seriesId: uuidSchema("Série"), + installmentIds: z + .array(uuidSchema("Parcela")) + .min(1, "Selecione pelo menos uma parcela para antecipar."), + anticipationPeriod: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }), + discount: z.coerce + .number() + .min(0, "Informe um desconto maior ou igual a zero.") + .optional() + .default(0), + pagadorId: uuidSchema("Pagador").optional(), + categoriaId: uuidSchema("Categoria").optional(), + note: z.string().trim().optional(), }); /** * Schema de validação para cancelar antecipação */ const cancelAnticipationSchema = z.object({ - anticipationId: uuidSchema("Antecipação"), + anticipationId: uuidSchema("Antecipação"), }); /** * Busca parcelas elegíveis para antecipação de uma série */ export async function getEligibleInstallmentsAction( - seriesId: string + seriesId: string, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Validar seriesId - const validatedSeriesId = uuidSchema("Série").parse(seriesId); + // Validar seriesId + const validatedSeriesId = uuidSchema("Série").parse(seriesId); - // Buscar todas as parcelas da série que estão elegíveis - const rows = await db.query.lancamentos.findMany({ - where: and( - eq(lancamentos.seriesId, validatedSeriesId), - eq(lancamentos.userId, user.id), - eq(lancamentos.condition, "Parcelado"), - // Apenas parcelas não pagas e não antecipadas - or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), - eq(lancamentos.isAnticipated, false) - ), - orderBy: [asc(lancamentos.currentInstallment)], - columns: { - id: true, - name: true, - amount: true, - period: true, - purchaseDate: true, - dueDate: true, - currentInstallment: true, - installmentCount: true, - paymentMethod: true, - categoriaId: true, - pagadorId: true, - }, - }); + // Buscar todas as parcelas da série que estão elegíveis + const rows = await db.query.lancamentos.findMany({ + where: and( + eq(lancamentos.seriesId, validatedSeriesId), + eq(lancamentos.userId, user.id), + eq(lancamentos.condition, "Parcelado"), + // Apenas parcelas não pagas e não antecipadas + or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), + eq(lancamentos.isAnticipated, false), + ), + orderBy: [asc(lancamentos.currentInstallment)], + columns: { + id: true, + name: true, + amount: true, + period: true, + purchaseDate: true, + dueDate: true, + currentInstallment: true, + installmentCount: true, + paymentMethod: true, + categoriaId: true, + pagadorId: true, + }, + }); - const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({ - id: row.id, - name: row.name, - amount: row.amount, - period: row.period, - purchaseDate: row.purchaseDate, - dueDate: row.dueDate, - currentInstallment: row.currentInstallment, - installmentCount: row.installmentCount, - paymentMethod: row.paymentMethod, - categoriaId: row.categoriaId, - pagadorId: row.pagadorId, - })); + const eligibleInstallments: EligibleInstallment[] = rows.map((row) => ({ + id: row.id, + name: row.name, + amount: row.amount, + period: row.period, + purchaseDate: row.purchaseDate, + dueDate: row.dueDate, + currentInstallment: row.currentInstallment, + installmentCount: row.installmentCount, + paymentMethod: row.paymentMethod, + categoriaId: row.categoriaId, + pagadorId: row.pagadorId, + })); - return { - success: true, - data: eligibleInstallments, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + data: eligibleInstallments, + }; + } catch (error) { + return handleActionError(error); + } } /** * Cria uma antecipação de parcelas */ export async function createInstallmentAnticipationAction( - input: CreateAnticipationInput + input: CreateAnticipationInput, ): Promise { - try { - const user = await getUser(); - const data = createAnticipationSchema.parse(input); + try { + const user = await getUser(); + const data = createAnticipationSchema.parse(input); - // 1. Validar parcelas selecionadas - const installments = await db.query.lancamentos.findMany({ - where: and( - inArray(lancamentos.id, data.installmentIds), - eq(lancamentos.userId, user.id), - eq(lancamentos.seriesId, data.seriesId), - or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), - eq(lancamentos.isAnticipated, false) - ), - }); + // 1. Validar parcelas selecionadas + const installments = await db.query.lancamentos.findMany({ + where: and( + inArray(lancamentos.id, data.installmentIds), + eq(lancamentos.userId, user.id), + eq(lancamentos.seriesId, data.seriesId), + or(eq(lancamentos.isSettled, false), isNull(lancamentos.isSettled)), + eq(lancamentos.isAnticipated, false), + ), + }); - if (installments.length !== data.installmentIds.length) { - return { - success: false, - error: "Algumas parcelas não estão elegíveis para antecipação.", - }; - } + if (installments.length !== data.installmentIds.length) { + return { + success: false, + error: "Algumas parcelas não estão elegíveis para antecipação.", + }; + } - if (installments.length === 0) { - return { - success: false, - error: "Nenhuma parcela selecionada para antecipação.", - }; - } + if (installments.length === 0) { + return { + success: false, + error: "Nenhuma parcela selecionada para antecipação.", + }; + } - // 2. Calcular valor total - const totalAmountCents = installments.reduce( - (sum, inst) => sum + Number(inst.amount) * 100, - 0 - ); - const totalAmount = totalAmountCents / 100; - const totalAmountAbs = Math.abs(totalAmount); + // 2. Calcular valor total + const totalAmountCents = installments.reduce( + (sum, inst) => sum + Number(inst.amount) * 100, + 0, + ); + const totalAmount = totalAmountCents / 100; + const totalAmountAbs = Math.abs(totalAmount); - // 2.1. Aplicar desconto - const discount = data.discount || 0; + // 2.1. Aplicar desconto + const discount = data.discount || 0; - // 2.2. Validar que o desconto não é maior que o valor absoluto total - if (discount > totalAmountAbs) { - return { - success: false, - error: "O desconto não pode ser maior que o valor total das parcelas.", - }; - } + // 2.2. Validar que o desconto não é maior que o valor absoluto total + if (discount > totalAmountAbs) { + return { + success: false, + error: "O desconto não pode ser maior que o valor total das parcelas.", + }; + } - // 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa) - const finalAmount = totalAmount < 0 - ? totalAmount + discount // Despesa: -1000 + 20 = -980 - : totalAmount - discount; // Receita: 1000 - 20 = 980 + // 2.3. Calcular valor final (se negativo, soma o desconto para reduzir a despesa) + const finalAmount = + totalAmount < 0 + ? totalAmount + discount // Despesa: -1000 + 20 = -980 + : totalAmount - discount; // Receita: 1000 - 20 = 980 - // 3. Pegar dados da primeira parcela para referência - const firstInstallment = installments[0]!; + // 3. Pegar dados da primeira parcela para referência + const firstInstallment = installments[0]!; - // 4. Criar lançamento e antecipação em transação - await db.transaction(async (tx) => { - // 4.1. Criar o lançamento de antecipação (com desconto aplicado) - const [newLancamento] = await tx - .insert(lancamentos) - .values({ - name: generateAnticipationDescription( - firstInstallment.name, - installments.length - ), - condition: "À vista", - transactionType: firstInstallment.transactionType, - paymentMethod: firstInstallment.paymentMethod, - amount: formatDecimalForDbRequired(finalAmount), - purchaseDate: new Date(), - period: data.anticipationPeriod, - dueDate: null, - isSettled: false, - pagadorId: data.pagadorId ?? firstInstallment.pagadorId, - categoriaId: data.categoriaId ?? firstInstallment.categoriaId, - cartaoId: firstInstallment.cartaoId, - contaId: firstInstallment.contaId, - note: - data.note || - generateAnticipationNote( - installments.map((inst) => ({ - id: inst.id, - name: inst.name, - amount: inst.amount, - period: inst.period, - purchaseDate: inst.purchaseDate, - dueDate: inst.dueDate, - currentInstallment: inst.currentInstallment, - installmentCount: inst.installmentCount, - paymentMethod: inst.paymentMethod, - categoriaId: inst.categoriaId, - pagadorId: inst.pagadorId, - })) - ), - userId: user.id, - installmentCount: null, - currentInstallment: null, - recurrenceCount: null, - isAnticipated: false, - isDivided: false, - seriesId: null, - transferId: null, - anticipationId: null, - boletoPaymentDate: null, - }) - .returning(); + // 4. Criar lançamento e antecipação em transação + await db.transaction(async (tx) => { + // 4.1. Criar o lançamento de antecipação (com desconto aplicado) + const [newLancamento] = await tx + .insert(lancamentos) + .values({ + name: generateAnticipationDescription( + firstInstallment.name, + installments.length, + ), + condition: "À vista", + transactionType: firstInstallment.transactionType, + paymentMethod: firstInstallment.paymentMethod, + amount: formatDecimalForDbRequired(finalAmount), + purchaseDate: new Date(), + period: data.anticipationPeriod, + dueDate: null, + isSettled: false, + pagadorId: data.pagadorId ?? firstInstallment.pagadorId, + categoriaId: data.categoriaId ?? firstInstallment.categoriaId, + cartaoId: firstInstallment.cartaoId, + contaId: firstInstallment.contaId, + note: + data.note || + generateAnticipationNote( + installments.map((inst) => ({ + id: inst.id, + name: inst.name, + amount: inst.amount, + period: inst.period, + purchaseDate: inst.purchaseDate, + dueDate: inst.dueDate, + currentInstallment: inst.currentInstallment, + installmentCount: inst.installmentCount, + paymentMethod: inst.paymentMethod, + categoriaId: inst.categoriaId, + pagadorId: inst.pagadorId, + })), + ), + userId: user.id, + installmentCount: null, + currentInstallment: null, + recurrenceCount: null, + isAnticipated: false, + isDivided: false, + seriesId: null, + transferId: null, + anticipationId: null, + boletoPaymentDate: null, + }) + .returning(); - // 4.2. Criar registro de antecipação - const [anticipation] = await tx - .insert(installmentAnticipations) - .values({ - seriesId: data.seriesId, - anticipationPeriod: data.anticipationPeriod, - anticipationDate: new Date(), - anticipatedInstallmentIds: data.installmentIds, - totalAmount: formatDecimalForDbRequired(totalAmount), - installmentCount: installments.length, - discount: formatDecimalForDbRequired(discount), - lancamentoId: newLancamento.id, - pagadorId: data.pagadorId ?? firstInstallment.pagadorId, - categoriaId: data.categoriaId ?? firstInstallment.categoriaId, - note: data.note || null, - userId: user.id, - }) - .returning(); + // 4.2. Criar registro de antecipação + const [anticipation] = await tx + .insert(installmentAnticipations) + .values({ + seriesId: data.seriesId, + anticipationPeriod: data.anticipationPeriod, + anticipationDate: new Date(), + anticipatedInstallmentIds: data.installmentIds, + totalAmount: formatDecimalForDbRequired(totalAmount), + installmentCount: installments.length, + discount: formatDecimalForDbRequired(discount), + lancamentoId: newLancamento.id, + pagadorId: data.pagadorId ?? firstInstallment.pagadorId, + categoriaId: data.categoriaId ?? firstInstallment.categoriaId, + note: data.note || null, + userId: user.id, + }) + .returning(); - // 4.3. Marcar parcelas como antecipadas e zerar seus valores - await tx - .update(lancamentos) - .set({ - isAnticipated: true, - anticipationId: anticipation.id, - amount: "0", // Zera o valor para não contar em dobro - }) - .where(inArray(lancamentos.id, data.installmentIds)); - }); + // 4.3. Marcar parcelas como antecipadas e zerar seus valores + await tx + .update(lancamentos) + .set({ + isAnticipated: true, + anticipationId: anticipation.id, + amount: "0", // Zera o valor para não contar em dobro + }) + .where(inArray(lancamentos.id, data.installmentIds)); + }); - revalidatePath("/lancamentos"); - revalidatePath("/dashboard"); + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); - return { - success: true, - message: `${installments.length} ${ - installments.length === 1 ? "parcela antecipada" : "parcelas antecipadas" - } com sucesso!`, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: `${installments.length} ${ + installments.length === 1 + ? "parcela antecipada" + : "parcelas antecipadas" + } com sucesso!`, + }; + } catch (error) { + return handleActionError(error); + } } /** * Busca histórico de antecipações de uma série */ export async function getInstallmentAnticipationsAction( - seriesId: string + seriesId: string, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Validar seriesId - const validatedSeriesId = uuidSchema("Série").parse(seriesId); + // Validar seriesId + const validatedSeriesId = uuidSchema("Série").parse(seriesId); - // Usar query builder ao invés de db.query para evitar problemas de tipagem - const anticipations = await db - .select({ - id: installmentAnticipations.id, - seriesId: installmentAnticipations.seriesId, - anticipationPeriod: installmentAnticipations.anticipationPeriod, - anticipationDate: installmentAnticipations.anticipationDate, - anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, - totalAmount: installmentAnticipations.totalAmount, - installmentCount: installmentAnticipations.installmentCount, - discount: installmentAnticipations.discount, - lancamentoId: installmentAnticipations.lancamentoId, - pagadorId: installmentAnticipations.pagadorId, - categoriaId: installmentAnticipations.categoriaId, - note: installmentAnticipations.note, - userId: installmentAnticipations.userId, - createdAt: installmentAnticipations.createdAt, - // Joins - lancamento: lancamentos, - pagador: pagadores, - categoria: categorias, - }) - .from(installmentAnticipations) - .leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) - .leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id)) - .leftJoin(categorias, eq(installmentAnticipations.categoriaId, categorias.id)) - .where( - and( - eq(installmentAnticipations.seriesId, validatedSeriesId), - eq(installmentAnticipations.userId, user.id) - ) - ) - .orderBy(desc(installmentAnticipations.createdAt)); + // Usar query builder ao invés de db.query para evitar problemas de tipagem + const anticipations = await db + .select({ + id: installmentAnticipations.id, + seriesId: installmentAnticipations.seriesId, + anticipationPeriod: installmentAnticipations.anticipationPeriod, + anticipationDate: installmentAnticipations.anticipationDate, + anticipatedInstallmentIds: + installmentAnticipations.anticipatedInstallmentIds, + totalAmount: installmentAnticipations.totalAmount, + installmentCount: installmentAnticipations.installmentCount, + discount: installmentAnticipations.discount, + lancamentoId: installmentAnticipations.lancamentoId, + pagadorId: installmentAnticipations.pagadorId, + categoriaId: installmentAnticipations.categoriaId, + note: installmentAnticipations.note, + userId: installmentAnticipations.userId, + createdAt: installmentAnticipations.createdAt, + // Joins + lancamento: lancamentos, + pagador: pagadores, + categoria: categorias, + }) + .from(installmentAnticipations) + .leftJoin( + lancamentos, + eq(installmentAnticipations.lancamentoId, lancamentos.id), + ) + .leftJoin(pagadores, eq(installmentAnticipations.pagadorId, pagadores.id)) + .leftJoin( + categorias, + eq(installmentAnticipations.categoriaId, categorias.id), + ) + .where( + and( + eq(installmentAnticipations.seriesId, validatedSeriesId), + eq(installmentAnticipations.userId, user.id), + ), + ) + .orderBy(desc(installmentAnticipations.createdAt)); - return { - success: true, - data: anticipations, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + data: anticipations, + }; + } catch (error) { + return handleActionError(error); + } } /** @@ -338,134 +346,138 @@ export async function getInstallmentAnticipationsAction( * Remove o lançamento de antecipação e restaura as parcelas originais */ export async function cancelInstallmentAnticipationAction( - input: CancelAnticipationInput + input: CancelAnticipationInput, ): Promise { - try { - const user = await getUser(); - const data = cancelAnticipationSchema.parse(input); + try { + const user = await getUser(); + const data = cancelAnticipationSchema.parse(input); - await db.transaction(async (tx) => { - // 1. Buscar antecipação usando query builder - const anticipationRows = await tx - .select({ - id: installmentAnticipations.id, - seriesId: installmentAnticipations.seriesId, - anticipationPeriod: installmentAnticipations.anticipationPeriod, - anticipationDate: installmentAnticipations.anticipationDate, - anticipatedInstallmentIds: installmentAnticipations.anticipatedInstallmentIds, - totalAmount: installmentAnticipations.totalAmount, - installmentCount: installmentAnticipations.installmentCount, - discount: installmentAnticipations.discount, - lancamentoId: installmentAnticipations.lancamentoId, - pagadorId: installmentAnticipations.pagadorId, - categoriaId: installmentAnticipations.categoriaId, - note: installmentAnticipations.note, - userId: installmentAnticipations.userId, - createdAt: installmentAnticipations.createdAt, - lancamento: lancamentos, - }) - .from(installmentAnticipations) - .leftJoin(lancamentos, eq(installmentAnticipations.lancamentoId, lancamentos.id)) - .where( - and( - eq(installmentAnticipations.id, data.anticipationId), - eq(installmentAnticipations.userId, user.id) - ) - ) - .limit(1); + await db.transaction(async (tx) => { + // 1. Buscar antecipação usando query builder + const anticipationRows = await tx + .select({ + id: installmentAnticipations.id, + seriesId: installmentAnticipations.seriesId, + anticipationPeriod: installmentAnticipations.anticipationPeriod, + anticipationDate: installmentAnticipations.anticipationDate, + anticipatedInstallmentIds: + installmentAnticipations.anticipatedInstallmentIds, + totalAmount: installmentAnticipations.totalAmount, + installmentCount: installmentAnticipations.installmentCount, + discount: installmentAnticipations.discount, + lancamentoId: installmentAnticipations.lancamentoId, + pagadorId: installmentAnticipations.pagadorId, + categoriaId: installmentAnticipations.categoriaId, + note: installmentAnticipations.note, + userId: installmentAnticipations.userId, + createdAt: installmentAnticipations.createdAt, + lancamento: lancamentos, + }) + .from(installmentAnticipations) + .leftJoin( + lancamentos, + eq(installmentAnticipations.lancamentoId, lancamentos.id), + ) + .where( + and( + eq(installmentAnticipations.id, data.anticipationId), + eq(installmentAnticipations.userId, user.id), + ), + ) + .limit(1); - const anticipation = anticipationRows[0]; + const anticipation = anticipationRows[0]; - if (!anticipation) { - throw new Error("Antecipação não encontrada."); - } + if (!anticipation) { + throw new Error("Antecipação não encontrada."); + } - // 2. Verificar se o lançamento já foi pago - if (anticipation.lancamento?.isSettled === true) { - throw new Error( - "Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro." - ); - } + // 2. Verificar se o lançamento já foi pago + if (anticipation.lancamento?.isSettled === true) { + throw new Error( + "Não é possível cancelar uma antecipação já paga. Remova o pagamento primeiro.", + ); + } - // 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade) - const originalTotalAmount = Number(anticipation.totalAmount); - const originalValuePerInstallment = - originalTotalAmount / anticipation.installmentCount; + // 3. Calcular valor original por parcela (totalAmount sem desconto / quantidade) + const originalTotalAmount = Number(anticipation.totalAmount); + const originalValuePerInstallment = + originalTotalAmount / anticipation.installmentCount; - // 4. Remover flag de antecipação e restaurar valores das parcelas - await tx - .update(lancamentos) - .set({ - isAnticipated: false, - anticipationId: null, - amount: formatDecimalForDbRequired(originalValuePerInstallment), - }) - .where( - inArray( - lancamentos.id, - anticipation.anticipatedInstallmentIds as string[] - ) - ); + // 4. Remover flag de antecipação e restaurar valores das parcelas + await tx + .update(lancamentos) + .set({ + isAnticipated: false, + anticipationId: null, + amount: formatDecimalForDbRequired(originalValuePerInstallment), + }) + .where( + inArray( + lancamentos.id, + anticipation.anticipatedInstallmentIds as string[], + ), + ); - // 5. Deletar lançamento de antecipação - await tx - .delete(lancamentos) - .where(eq(lancamentos.id, anticipation.lancamentoId)); + // 5. Deletar lançamento de antecipação + await tx + .delete(lancamentos) + .where(eq(lancamentos.id, anticipation.lancamentoId)); - // 6. Deletar registro de antecipação - await tx - .delete(installmentAnticipations) - .where(eq(installmentAnticipations.id, data.anticipationId)); - }); + // 6. Deletar registro de antecipação + await tx + .delete(installmentAnticipations) + .where(eq(installmentAnticipations.id, data.anticipationId)); + }); - revalidatePath("/lancamentos"); - revalidatePath("/dashboard"); + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); - return { - success: true, - message: "Antecipação cancelada com sucesso!", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: "Antecipação cancelada com sucesso!", + }; + } catch (error) { + return handleActionError(error); + } } /** * Busca detalhes de uma antecipação específica */ export async function getAnticipationDetailsAction( - anticipationId: string + anticipationId: string, ): Promise> { - try { - const user = await getUser(); + try { + const user = await getUser(); - // Validar anticipationId - const validatedId = uuidSchema("Antecipação").parse(anticipationId); + // Validar anticipationId + const validatedId = uuidSchema("Antecipação").parse(anticipationId); - const anticipation = await db.query.installmentAnticipations.findFirst({ - where: and( - eq(installmentAnticipations.id, validatedId), - eq(installmentAnticipations.userId, user.id) - ), - with: { - lancamento: true, - pagador: true, - categoria: true, - }, - }); + const anticipation = await db.query.installmentAnticipations.findFirst({ + where: and( + eq(installmentAnticipations.id, validatedId), + eq(installmentAnticipations.userId, user.id), + ), + with: { + lancamento: true, + pagador: true, + categoria: true, + }, + }); - if (!anticipation) { - return { - success: false, - error: "Antecipação não encontrada.", - }; - } + if (!anticipation) { + return { + success: false, + error: "Antecipação não encontrada.", + }; + } - return { - success: true, - data: anticipation, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + data: anticipation, + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/lancamentos/data.ts b/app/(dashboard)/lancamentos/data.ts index 20c3df8..27b4efe 100644 --- a/app/(dashboard)/lancamentos/data.ts +++ b/app/(dashboard)/lancamentos/data.ts @@ -1,41 +1,47 @@ -import { lancamentos, contas, pagadores, cartoes, categorias } from "@/db/schema"; +import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm"; +import { + cartoes, + categorias, + contas, + lancamentos, + pagadores, +} from "@/db/schema"; import { INITIAL_BALANCE_NOTE } from "@/lib/accounts/constants"; import { db } from "@/lib/db"; -import { and, desc, eq, isNull, ne, or, type SQL } from "drizzle-orm"; export async function fetchLancamentos(filters: SQL[]) { - const lancamentoRows = await db - .select({ - lancamento: lancamentos, - pagador: pagadores, - conta: contas, - cartao: cartoes, - categoria: categorias, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(contas, eq(lancamentos.contaId, contas.id)) - .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where( - and( - ...filters, - // Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true - or( - ne(lancamentos.note, INITIAL_BALANCE_NOTE), - isNull(contas.excludeInitialBalanceFromIncome), - eq(contas.excludeInitialBalanceFromIncome, false) - ) - ) - ) - .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); + const lancamentoRows = await db + .select({ + lancamento: lancamentos, + pagador: pagadores, + conta: contas, + cartao: cartoes, + categoria: categorias, + }) + .from(lancamentos) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(contas, eq(lancamentos.contaId, contas.id)) + .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) + .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + ...filters, + // Excluir saldos iniciais de contas que têm excludeInitialBalanceFromIncome = true + or( + ne(lancamentos.note, INITIAL_BALANCE_NOTE), + isNull(contas.excludeInitialBalanceFromIncome), + eq(contas.excludeInitialBalanceFromIncome, false), + ), + ), + ) + .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); - // Transformar resultado para o formato esperado - return lancamentoRows.map((row) => ({ - ...row.lancamento, - pagador: row.pagador, - conta: row.conta, - cartao: row.cartao, - categoria: row.categoria, - })); + // Transformar resultado para o formato esperado + return lancamentoRows.map((row) => ({ + ...row.lancamento, + pagador: row.pagador, + conta: row.conta, + cartao: row.cartao, + categoria: row.categoria, + })); } diff --git a/app/(dashboard)/lancamentos/layout.tsx b/app/(dashboard)/lancamentos/layout.tsx index b1e151f..e069799 100644 --- a/app/(dashboard)/lancamentos/layout.tsx +++ b/app/(dashboard)/lancamentos/layout.tsx @@ -1,25 +1,25 @@ -import PageDescription from "@/components/page-description"; import { RiArrowLeftRightLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Lançamentos | Opensheets", + title: "Lançamentos | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Lançamentos" - subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo + return ( +
+ } + title="Lançamentos" + subtitle="Acompanhe todos os lançamentos financeiros do mês selecionado incluindo receitas, despesas e transações previstas. Use o seletor abaixo para navegar pelos meses e visualizar as movimentações correspondentes." - /> - {children} -
- ); + /> + {children} +
+ ); } diff --git a/app/(dashboard)/lancamentos/loading.tsx b/app/(dashboard)/lancamentos/loading.tsx index 8d6aab8..13f1b0e 100644 --- a/app/(dashboard)/lancamentos/loading.tsx +++ b/app/(dashboard)/lancamentos/loading.tsx @@ -1,6 +1,6 @@ import { - FilterSkeleton, - TransactionsTableSkeleton, + FilterSkeleton, + TransactionsTableSkeleton, } from "@/components/skeletons"; import { Skeleton } from "@/components/ui/skeleton"; @@ -9,24 +9,24 @@ import { Skeleton } from "@/components/ui/skeleton"; * Mantém o mesmo layout da página final */ export default function LancamentosLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
-
- {/* Header com título e botão */} -
- - -
+
+ {/* Header com título e botão */} +
+ + +
- {/* Filtros */} - + {/* Filtros */} + - {/* Tabela */} - -
-
- ); + {/* Tabela */} + +
+
+ ); } diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx index cc746f0..7ad5fc9 100644 --- a/app/(dashboard)/lancamentos/page.tsx +++ b/app/(dashboard)/lancamentos/page.tsx @@ -1,86 +1,86 @@ -import MonthNavigation from "@/components/month-picker/month-navigation"; import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { getUserId } from "@/lib/auth/server"; import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type ResolvedSearchParams, + buildLancamentoWhere, + buildOptionSets, + buildSluggedFilters, + buildSlugMaps, + extractLancamentoSearchFilters, + fetchLancamentoFilterSources, + getSingleParam, + mapLancamentosData, + type ResolvedSearchParams, } from "@/lib/lancamentos/page-helpers"; import { parsePeriodParam } from "@/lib/utils/period"; -import { fetchLancamentos } from "./data"; import { getRecentEstablishmentsAction } from "./actions"; +import { fetchLancamentos } from "./data"; type PageSearchParams = Promise; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; export default async function Page({ searchParams }: PageProps) { - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw); + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw); - const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const filterSources = await fetchLancamentoFilterSources(userId); + const filterSources = await fetchLancamentoFilterSources(userId); - const sluggedFilters = buildSluggedFilters(filterSources); - const slugMaps = buildSlugMaps(sluggedFilters); + const sluggedFilters = buildSluggedFilters(filterSources); + const slugMaps = buildSlugMaps(sluggedFilters); - const filters = buildLancamentoWhere({ - userId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - }); + const filters = buildLancamentoWhere({ + userId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + }); - const lancamentoRows = await fetchLancamentos(filters); - const lancamentosData = mapLancamentosData(lancamentoRows); + const lancamentoRows = await fetchLancamentos(filters); + const lancamentosData = mapLancamentosData(lancamentoRows); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); - const estabelecimentos = await getRecentEstablishmentsAction(); + const estabelecimentos = await getRecentEstablishmentsAction(); - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index d78b1b2..edca242 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -10,66 +10,66 @@ import { parsePeriodParam } from "@/lib/utils/period"; import { fetchPendingInboxCount } from "./pre-lancamentos/data"; export default async function DashboardLayout({ - children, - searchParams, + children, + searchParams, }: Readonly<{ - children: React.ReactNode; - searchParams?: Promise>; + children: React.ReactNode; + searchParams?: Promise>; }>) { - const session = await getUserSession(); - const pagadoresList = await fetchPagadoresWithAccess(session.user.id); + const session = await getUserSession(); + const pagadoresList = await fetchPagadoresWithAccess(session.user.id); - // Encontrar o pagador admin do usuário - const adminPagador = pagadoresList.find( - (p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id, - ); + // Encontrar o pagador admin do usuário + const adminPagador = pagadoresList.find( + (p) => p.role === PAGADOR_ROLE_ADMIN && p.userId === session.user.id, + ); - // Buscar notificações para o período atual - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = resolvedSearchParams?.periodo; - const singlePeriodoParam = - typeof periodoParam === "string" - ? periodoParam - : Array.isArray(periodoParam) - ? periodoParam[0] - : null; - const { period: currentPeriod } = parsePeriodParam( - singlePeriodoParam ?? null, - ); - const notificationsSnapshot = await fetchDashboardNotifications( - session.user.id, - currentPeriod, - ); + // Buscar notificações para o período atual + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = resolvedSearchParams?.periodo; + const singlePeriodoParam = + typeof periodoParam === "string" + ? periodoParam + : Array.isArray(periodoParam) + ? periodoParam[0] + : null; + const { period: currentPeriod } = parsePeriodParam( + singlePeriodoParam ?? null, + ); + const notificationsSnapshot = await fetchDashboardNotifications( + session.user.id, + currentPeriod, + ); - // Buscar contagem de pré-lançamentos pendentes - const preLancamentosCount = await fetchPendingInboxCount(session.user.id); + // Buscar contagem de pré-lançamentos pendentes + const preLancamentosCount = await fetchPendingInboxCount(session.user.id); - return ( - - - ({ - id: item.id, - name: item.name, - avatarUrl: item.avatarUrl, - canEdit: item.canEdit, - }))} - preLancamentosCount={preLancamentosCount} - variant="sidebar" - /> - - -
-
-
- {children} -
-
-
-
-
-
- ); + return ( + + + ({ + id: item.id, + name: item.name, + avatarUrl: item.avatarUrl, + canEdit: item.canEdit, + }))} + preLancamentosCount={preLancamentosCount} + variant="sidebar" + /> + + +
+
+
+ {children} +
+
+
+
+
+
+ ); } diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts index ebceb94..a788d68 100644 --- a/app/(dashboard)/orcamentos/actions.ts +++ b/app/(dashboard)/orcamentos/actions.ts @@ -1,46 +1,46 @@ "use server"; -import { categorias, orcamentos } from "@/db/schema"; -import { - type ActionResult, - handleActionError, - revalidateForEntity, -} from "@/lib/actions/helpers"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { periodSchema, uuidSchema } from "@/lib/schemas/common"; -import { - formatDecimalForDbRequired, - normalizeDecimalInput, -} from "@/lib/utils/currency"; import { and, eq, ne } from "drizzle-orm"; import { z } from "zod"; +import { categorias, orcamentos } from "@/db/schema"; +import { + type ActionResult, + handleActionError, + revalidateForEntity, +} from "@/lib/actions/helpers"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { periodSchema, uuidSchema } from "@/lib/schemas/common"; +import { + formatDecimalForDbRequired, + normalizeDecimalInput, +} from "@/lib/utils/currency"; const budgetBaseSchema = z.object({ - categoriaId: uuidSchema("Categoria"), - period: periodSchema, - amount: z - .string({ message: "Informe o valor limite." }) - .trim() - .min(1, "Informe o valor limite.") - .transform((value) => normalizeDecimalInput(value)) - .refine( - (value) => !Number.isNaN(Number.parseFloat(value)), - "Informe um valor limite válido." - ) - .transform((value) => Number.parseFloat(value)) - .refine( - (value) => value >= 0, - "O valor limite deve ser maior ou igual a zero." - ), + categoriaId: uuidSchema("Categoria"), + period: periodSchema, + amount: z + .string({ message: "Informe o valor limite." }) + .trim() + .min(1, "Informe o valor limite.") + .transform((value) => normalizeDecimalInput(value)) + .refine( + (value) => !Number.isNaN(Number.parseFloat(value)), + "Informe um valor limite válido.", + ) + .transform((value) => Number.parseFloat(value)) + .refine( + (value) => value >= 0, + "O valor limite deve ser maior ou igual a zero.", + ), }); const createBudgetSchema = budgetBaseSchema; const updateBudgetSchema = budgetBaseSchema.extend({ - id: uuidSchema("Orçamento"), + id: uuidSchema("Orçamento"), }); const deleteBudgetSchema = z.object({ - id: uuidSchema("Orçamento"), + id: uuidSchema("Orçamento"), }); type BudgetCreateInput = z.infer; @@ -48,229 +48,227 @@ type BudgetUpdateInput = z.infer; type BudgetDeleteInput = z.infer; const ensureCategory = async (userId: string, categoriaId: string) => { - const category = await db.query.categorias.findFirst({ - columns: { - id: true, - type: true, - }, - where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), - }); + const category = await db.query.categorias.findFirst({ + columns: { + id: true, + type: true, + }, + where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), + }); - if (!category) { - throw new Error("Categoria não encontrada."); - } + if (!category) { + throw new Error("Categoria não encontrada."); + } - if (category.type !== "despesa") { - throw new Error("Selecione uma categoria de despesa."); - } + if (category.type !== "despesa") { + throw new Error("Selecione uma categoria de despesa."); + } }; export async function createBudgetAction( - input: BudgetCreateInput + input: BudgetCreateInput, ): Promise { - try { - const user = await getUser(); - const data = createBudgetSchema.parse(input); + try { + const user = await getUser(); + const data = createBudgetSchema.parse(input); - await ensureCategory(user.id, data.categoriaId); + await ensureCategory(user.id, data.categoriaId); - const duplicateConditions = [ - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period), - eq(orcamentos.categoriaId, data.categoriaId), - ] as const; + const duplicateConditions = [ + eq(orcamentos.userId, user.id), + eq(orcamentos.period, data.period), + eq(orcamentos.categoriaId, data.categoriaId), + ] as const; - const duplicate = await db.query.orcamentos.findFirst({ - columns: { id: true }, - where: and(...duplicateConditions), - }); + const duplicate = await db.query.orcamentos.findFirst({ + columns: { id: true }, + where: and(...duplicateConditions), + }); - if (duplicate) { - return { - success: false, - error: - "Já existe um orçamento para esta categoria no período selecionado.", - }; - } + if (duplicate) { + return { + success: false, + error: + "Já existe um orçamento para esta categoria no período selecionado.", + }; + } - await db.insert(orcamentos).values({ - amount: formatDecimalForDbRequired(data.amount), - period: data.period, - userId: user.id, - categoriaId: data.categoriaId, - }); + await db.insert(orcamentos).values({ + amount: formatDecimalForDbRequired(data.amount), + period: data.period, + userId: user.id, + categoriaId: data.categoriaId, + }); - revalidateForEntity("orcamentos"); + revalidateForEntity("orcamentos"); - return { success: true, message: "Orçamento criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Orçamento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updateBudgetAction( - input: BudgetUpdateInput + input: BudgetUpdateInput, ): Promise { - try { - const user = await getUser(); - const data = updateBudgetSchema.parse(input); + try { + const user = await getUser(); + const data = updateBudgetSchema.parse(input); - await ensureCategory(user.id, data.categoriaId); + await ensureCategory(user.id, data.categoriaId); - const duplicateConditions = [ - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period), - eq(orcamentos.categoriaId, data.categoriaId), - ne(orcamentos.id, data.id), - ] as const; + const duplicateConditions = [ + eq(orcamentos.userId, user.id), + eq(orcamentos.period, data.period), + eq(orcamentos.categoriaId, data.categoriaId), + ne(orcamentos.id, data.id), + ] as const; - const duplicate = await db.query.orcamentos.findFirst({ - columns: { id: true }, - where: and(...duplicateConditions), - }); + const duplicate = await db.query.orcamentos.findFirst({ + columns: { id: true }, + where: and(...duplicateConditions), + }); - if (duplicate) { - return { - success: false, - error: - "Já existe um orçamento para esta categoria no período selecionado.", - }; - } + if (duplicate) { + return { + success: false, + error: + "Já existe um orçamento para esta categoria no período selecionado.", + }; + } - const [updated] = await db - .update(orcamentos) - .set({ - amount: formatDecimalForDbRequired(data.amount), - period: data.period, - categoriaId: data.categoriaId, - }) - .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) - .returning({ id: orcamentos.id }); + const [updated] = await db + .update(orcamentos) + .set({ + amount: formatDecimalForDbRequired(data.amount), + period: data.period, + categoriaId: data.categoriaId, + }) + .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) + .returning({ id: orcamentos.id }); - if (!updated) { - return { - success: false, - error: "Orçamento não encontrado.", - }; - } + if (!updated) { + return { + success: false, + error: "Orçamento não encontrado.", + }; + } - revalidateForEntity("orcamentos"); + revalidateForEntity("orcamentos"); - return { success: true, message: "Orçamento atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Orçamento atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deleteBudgetAction( - input: BudgetDeleteInput + input: BudgetDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteBudgetSchema.parse(input); + try { + const user = await getUser(); + const data = deleteBudgetSchema.parse(input); - const [deleted] = await db - .delete(orcamentos) - .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) - .returning({ id: orcamentos.id }); + const [deleted] = await db + .delete(orcamentos) + .where(and(eq(orcamentos.id, data.id), eq(orcamentos.userId, user.id))) + .returning({ id: orcamentos.id }); - if (!deleted) { - return { - success: false, - error: "Orçamento não encontrado.", - }; - } + if (!deleted) { + return { + success: false, + error: "Orçamento não encontrado.", + }; + } - revalidateForEntity("orcamentos"); + revalidateForEntity("orcamentos"); - return { success: true, message: "Orçamento removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Orçamento removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } } const duplicatePreviousMonthSchema = z.object({ - period: periodSchema, + period: periodSchema, }); -type DuplicatePreviousMonthInput = z.infer< - typeof duplicatePreviousMonthSchema ->; +type DuplicatePreviousMonthInput = z.infer; export async function duplicatePreviousMonthBudgetsAction( - input: DuplicatePreviousMonthInput + input: DuplicatePreviousMonthInput, ): Promise { - try { - const user = await getUser(); - const data = duplicatePreviousMonthSchema.parse(input); + try { + const user = await getUser(); + const data = duplicatePreviousMonthSchema.parse(input); - // Calcular mês anterior - const [year, month] = data.period.split("-").map(Number); - const currentDate = new Date(year, month - 1, 1); - const previousDate = new Date(currentDate); - previousDate.setMonth(previousDate.getMonth() - 1); + // Calcular mês anterior + const [year, month] = data.period.split("-").map(Number); + const currentDate = new Date(year, month - 1, 1); + const previousDate = new Date(currentDate); + previousDate.setMonth(previousDate.getMonth() - 1); - const prevYear = previousDate.getFullYear(); - const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0"); - const previousPeriod = `${prevYear}-${prevMonth}`; + const prevYear = previousDate.getFullYear(); + const prevMonth = String(previousDate.getMonth() + 1).padStart(2, "0"); + const previousPeriod = `${prevYear}-${prevMonth}`; - // Buscar orçamentos do mês anterior - const previousBudgets = await db.query.orcamentos.findMany({ - where: and( - eq(orcamentos.userId, user.id), - eq(orcamentos.period, previousPeriod) - ), - }); + // Buscar orçamentos do mês anterior + const previousBudgets = await db.query.orcamentos.findMany({ + where: and( + eq(orcamentos.userId, user.id), + eq(orcamentos.period, previousPeriod), + ), + }); - if (previousBudgets.length === 0) { - return { - success: false, - error: "Não foram encontrados orçamentos no mês anterior.", - }; - } + if (previousBudgets.length === 0) { + return { + success: false, + error: "Não foram encontrados orçamentos no mês anterior.", + }; + } - // Buscar orçamentos existentes do mês atual - const currentBudgets = await db.query.orcamentos.findMany({ - where: and( - eq(orcamentos.userId, user.id), - eq(orcamentos.period, data.period) - ), - }); + // Buscar orçamentos existentes do mês atual + const currentBudgets = await db.query.orcamentos.findMany({ + where: and( + eq(orcamentos.userId, user.id), + eq(orcamentos.period, data.period), + ), + }); - // Filtrar para evitar duplicatas - const existingCategoryIds = new Set( - currentBudgets.map((b) => b.categoriaId) - ); + // Filtrar para evitar duplicatas + const existingCategoryIds = new Set( + currentBudgets.map((b) => b.categoriaId), + ); - const budgetsToCopy = previousBudgets.filter( - (b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId) - ); + const budgetsToCopy = previousBudgets.filter( + (b) => b.categoriaId && !existingCategoryIds.has(b.categoriaId), + ); - if (budgetsToCopy.length === 0) { - return { - success: false, - error: - "Todas as categorias do mês anterior já possuem orçamento neste mês.", - }; - } + if (budgetsToCopy.length === 0) { + return { + success: false, + error: + "Todas as categorias do mês anterior já possuem orçamento neste mês.", + }; + } - // Inserir novos orçamentos - await db.insert(orcamentos).values( - budgetsToCopy.map((b) => ({ - amount: b.amount, - period: data.period, - userId: user.id, - categoriaId: b.categoriaId!, - })) - ); + // Inserir novos orçamentos + await db.insert(orcamentos).values( + budgetsToCopy.map((b) => ({ + amount: b.amount, + period: data.period, + userId: user.id, + categoriaId: b.categoriaId!, + })), + ); - revalidateForEntity("orcamentos"); + revalidateForEntity("orcamentos"); - return { - success: true, - message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: `${budgetsToCopy.length} orçamento${budgetsToCopy.length > 1 ? "s" : ""} duplicado${budgetsToCopy.length > 1 ? "s" : ""} com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/orcamentos/data.ts b/app/(dashboard)/orcamentos/data.ts index a2347eb..8e336d1 100644 --- a/app/(dashboard)/orcamentos/data.ts +++ b/app/(dashboard)/orcamentos/data.ts @@ -1,125 +1,127 @@ +import { and, asc, eq, inArray, sum } from "drizzle-orm"; import { - categorias, - lancamentos, - orcamentos, - type Orcamento, + categorias, + lancamentos, + type Orcamento, + orcamentos, } from "@/db/schema"; import { db } from "@/lib/db"; -import { and, asc, eq, inArray, sum } from "drizzle-orm"; const toNumber = (value: string | number | null | undefined) => { - if (typeof value === "number") return value; - if (typeof value === "string") { - const parsed = Number.parseFloat(value); - return Number.isNaN(parsed) ? 0 : parsed; - } - return 0; + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + return 0; }; export type BudgetData = { - id: string; - amount: number; - spent: number; - period: string; - createdAt: string; - category: { - id: string; - name: string; - icon: string | null; - } | null; + id: string; + amount: number; + spent: number; + period: string; + createdAt: string; + category: { + id: string; + name: string; + icon: string | null; + } | null; }; export type CategoryOption = { - id: string; - name: string; - icon: string | null; + id: string; + name: string; + icon: string | null; }; export async function fetchBudgetsForUser( - userId: string, - selectedPeriod: string + userId: string, + selectedPeriod: string, ): Promise<{ - budgets: BudgetData[]; - categoriesOptions: CategoryOption[]; + budgets: BudgetData[]; + categoriesOptions: CategoryOption[]; }> { - const [budgetRows, categoryRows] = await Promise.all([ - db.query.orcamentos.findMany({ - where: and( - eq(orcamentos.userId, userId), - eq(orcamentos.period, selectedPeriod) - ), - with: { - categoria: true, - }, - }), - db.query.categorias.findMany({ - columns: { - id: true, - name: true, - icon: true, - }, - where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")), - orderBy: asc(categorias.name), - }), - ]); + const [budgetRows, categoryRows] = await Promise.all([ + db.query.orcamentos.findMany({ + where: and( + eq(orcamentos.userId, userId), + eq(orcamentos.period, selectedPeriod), + ), + with: { + categoria: true, + }, + }), + db.query.categorias.findMany({ + columns: { + id: true, + name: true, + icon: true, + }, + where: and(eq(categorias.userId, userId), eq(categorias.type, "despesa")), + orderBy: asc(categorias.name), + }), + ]); - const categoryIds = budgetRows - .map((budget: Orcamento) => budget.categoriaId) - .filter((id: string | null): id is string => Boolean(id)); + const categoryIds = budgetRows + .map((budget: Orcamento) => budget.categoriaId) + .filter((id: string | null): id is string => Boolean(id)); - let totalsByCategory = new Map(); + let totalsByCategory = new Map(); - if (categoryIds.length > 0) { - const totals = await db - .select({ - categoriaId: lancamentos.categoriaId, - totalAmount: sum(lancamentos.amount).as("totalAmount"), - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - eq(lancamentos.period, selectedPeriod), - eq(lancamentos.transactionType, "Despesa"), - inArray(lancamentos.categoriaId, categoryIds) - ) - ) - .groupBy(lancamentos.categoriaId); + if (categoryIds.length > 0) { + const totals = await db + .select({ + categoriaId: lancamentos.categoriaId, + totalAmount: sum(lancamentos.amount).as("totalAmount"), + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, selectedPeriod), + eq(lancamentos.transactionType, "Despesa"), + inArray(lancamentos.categoriaId, categoryIds), + ), + ) + .groupBy(lancamentos.categoriaId); - totalsByCategory = new Map( - totals.map((row: { categoriaId: string | null; totalAmount: string | null }) => [ - row.categoriaId ?? "", - Math.abs(toNumber(row.totalAmount)), - ]) - ); - } + totalsByCategory = new Map( + totals.map( + (row: { categoriaId: string | null; totalAmount: string | null }) => [ + row.categoriaId ?? "", + Math.abs(toNumber(row.totalAmount)), + ], + ), + ); + } - const budgets = budgetRows - .map((budget: Orcamento) => ({ - id: budget.id, - amount: toNumber(budget.amount), - spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0, - period: budget.period, - createdAt: budget.createdAt.toISOString(), - category: budget.categoria - ? { - id: budget.categoria.id, - name: budget.categoria.name, - icon: budget.categoria.icon, - } - : null, - })) - .sort((a, b) => - (a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", { - sensitivity: "base", - }) - ); + const budgets = budgetRows + .map((budget: Orcamento) => ({ + id: budget.id, + amount: toNumber(budget.amount), + spent: totalsByCategory.get(budget.categoriaId ?? "") ?? 0, + period: budget.period, + createdAt: budget.createdAt.toISOString(), + category: budget.categoria + ? { + id: budget.categoria.id, + name: budget.categoria.name, + icon: budget.categoria.icon, + } + : null, + })) + .sort((a, b) => + (a.category?.name ?? "").localeCompare(b.category?.name ?? "", "pt-BR", { + sensitivity: "base", + }), + ); - const categoriesOptions = categoryRows.map((category) => ({ - id: category.id, - name: category.name, - icon: category.icon, - })); + const categoriesOptions = categoryRows.map((category) => ({ + id: category.id, + name: category.name, + icon: category.icon, + })); - return { budgets, categoriesOptions }; + return { budgets, categoriesOptions }; } diff --git a/app/(dashboard)/orcamentos/layout.tsx b/app/(dashboard)/orcamentos/layout.tsx index 0d1ba21..fce04a9 100644 --- a/app/(dashboard)/orcamentos/layout.tsx +++ b/app/(dashboard)/orcamentos/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiFundsLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Anotações | Opensheets", + title: "Anotações | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Orçamentos" - subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário." - /> - {children} -
- ); + return ( +
+ } + title="Orçamentos" + subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/orcamentos/loading.tsx b/app/(dashboard)/orcamentos/loading.tsx index 45fc824..042b8ea 100644 --- a/app/(dashboard)/orcamentos/loading.tsx +++ b/app/(dashboard)/orcamentos/loading.tsx @@ -5,64 +5,61 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: MonthPicker + Header + Grid de cards de orçamento */ export default function OrcamentosLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
-
- {/* Header */} -
-
- - -
- -
+
+ {/* Header */} +
+
+ + +
+ +
- {/* Grid de cards de orçamentos */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- {/* Categoria com ícone */} -
- -
- - -
-
+ {/* Grid de cards de orçamentos */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Categoria com ícone */} +
+ +
+ + +
+
- {/* Valor orçado */} -
- - -
+ {/* Valor orçado */} +
+ + +
- {/* Valor gasto */} -
- - -
+ {/* Valor gasto */} +
+ + +
- {/* Barra de progresso */} -
- - -
+ {/* Barra de progresso */} +
+ + +
- {/* Botões de ação */} -
- - -
-
- ))} -
-
-
- ); + {/* Botões de ação */} +
+ + +
+
+ ))} + + +
+ ); } diff --git a/app/(dashboard)/orcamentos/page.tsx b/app/(dashboard)/orcamentos/page.tsx index fa846ac..5b0c1d5 100644 --- a/app/(dashboard)/orcamentos/page.tsx +++ b/app/(dashboard)/orcamentos/page.tsx @@ -7,45 +7,48 @@ import { fetchBudgetsForUser } from "./data"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? value[0] ?? null : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; const capitalize = (value: string) => - value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1); + value.length === 0 ? value : value[0]?.toUpperCase() + value.slice(1); export default async function Page({ searchParams }: PageProps) { - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName: rawMonthName, - year, - } = parsePeriodParam(periodoParam); + const { + period: selectedPeriod, + monthName: rawMonthName, + year, + } = parsePeriodParam(periodoParam); - const periodLabel = `${capitalize(rawMonthName)} ${year}`; + const periodLabel = `${capitalize(rawMonthName)} ${year}`; - const { budgets, categoriesOptions } = await fetchBudgetsForUser(userId, selectedPeriod); + const { budgets, categoriesOptions } = await fetchBudgetsForUser( + userId, + selectedPeriod, + ); - return ( -
- - -
- ); + return ( +
+ + +
+ ); } diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts index 8883678..ab2810b 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/actions.ts +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -1,234 +1,234 @@ "use server"; -import { lancamentos, pagadores } from "@/db/schema"; -import { db } from "@/lib/db"; -import { getUser } from "@/lib/auth/server"; -import { - fetchPagadorBoletoStats, - fetchPagadorCardUsage, - fetchPagadorHistory, - fetchPagadorMonthlyBreakdown, -} from "@/lib/pagadores/details"; -import { displayPeriod } from "@/lib/utils/period"; import { and, desc, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { Resend } from "resend"; import { z } from "zod"; +import { lancamentos, pagadores } from "@/db/schema"; +import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; +import { + fetchPagadorBoletoStats, + fetchPagadorCardUsage, + fetchPagadorHistory, + fetchPagadorMonthlyBreakdown, +} from "@/lib/pagadores/details"; +import { displayPeriod } from "@/lib/utils/period"; const inputSchema = z.object({ - pagadorId: z.string().uuid("Pagador inválido."), - period: z - .string() - .regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."), + pagadorId: z.string().uuid("Pagador inválido."), + period: z + .string() + .regex(/^\d{4}-\d{2}$/, "Período inválido. Informe no formato AAAA-MM."), }); type ActionResult = - | { success: true; message: string } - | { success: false; error: string }; + | { success: true; message: string } + | { success: false; error: string }; const formatCurrency = (value: number) => - value.toLocaleString("pt-BR", { - style: "currency", - currency: "BRL", - maximumFractionDigits: 2, - }); + value.toLocaleString("pt-BR", { + style: "currency", + currency: "BRL", + maximumFractionDigits: 2, + }); const formatDate = (value: Date | null | undefined) => { - if (!value) return "—"; - return value.toLocaleDateString("pt-BR", { - day: "2-digit", - month: "short", - year: "numeric", - }); + if (!value) return "—"; + return value.toLocaleDateString("pt-BR", { + day: "2-digit", + month: "short", + year: "numeric", + }); }; // Escapa HTML para prevenir XSS const escapeHtml = (text: string | null | undefined): string => { - if (!text) return ""; - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); }; type LancamentoRow = { - id: string; - name: string | null; - paymentMethod: string | null; - condition: string | null; - amount: number; - transactionType: string | null; - purchaseDate: Date | null; + id: string; + name: string | null; + paymentMethod: string | null; + condition: string | null; + amount: number; + transactionType: string | null; + purchaseDate: Date | null; }; type BoletoItem = { - name: string; - amount: number; - dueDate: Date | null; + name: string; + amount: number; + dueDate: Date | null; }; type ParceladoItem = { - name: string; - totalAmount: number; - installmentCount: number; - currentInstallment: number; - installmentAmount: number; - purchaseDate: Date | null; + name: string; + totalAmount: number; + installmentCount: number; + currentInstallment: number; + installmentAmount: number; + purchaseDate: Date | null; }; type SummaryPayload = { - pagadorName: string; - periodLabel: string; - monthlyBreakdown: Awaited>; - historyData: Awaited>; - cardUsage: Awaited>; - boletoStats: Awaited>; - boletos: BoletoItem[]; - lancamentos: LancamentoRow[]; - parcelados: ParceladoItem[]; + pagadorName: string; + periodLabel: string; + monthlyBreakdown: Awaited>; + historyData: Awaited>; + cardUsage: Awaited>; + boletoStats: Awaited>; + boletos: BoletoItem[]; + lancamentos: LancamentoRow[]; + parcelados: ParceladoItem[]; }; const buildSectionHeading = (label: string) => - `

${label}

`; + `

${label}

`; const buildSummaryHtml = ({ - pagadorName, - periodLabel, - monthlyBreakdown, - historyData, - cardUsage, - boletoStats, - boletos, - lancamentos, - parcelados, + pagadorName, + periodLabel, + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletos, + lancamentos, + parcelados, }: SummaryPayload) => { - // Calcular máximo de despesas para barras de progresso - const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1); + // Calcular máximo de despesas para barras de progresso + const maxDespesas = Math.max(...historyData.map((p) => p.despesas), 1); - const historyRows = - historyData.length > 0 - ? historyData - .map((point) => { - const percentage = (point.despesas / maxDespesas) * 100; - const barColor = - point.despesas > maxDespesas * 0.8 - ? "#ef4444" - : point.despesas > maxDespesas * 0.5 - ? "#f59e0b" - : "#10b981"; + const historyRows = + historyData.length > 0 + ? historyData + .map((point) => { + const percentage = (point.despesas / maxDespesas) * 100; + const barColor = + point.despesas > maxDespesas * 0.8 + ? "#ef4444" + : point.despesas > maxDespesas * 0.5 + ? "#f59e0b" + : "#10b981"; - return ` + return ` ${escapeHtml( - point.label - )} + point.label, + )}
${formatCurrency( - point.despesas - )} + point.despesas, + )}
`; - }) - .join("") - : `Sem histórico suficiente.`; + }) + .join("") + : `Sem histórico suficiente.`; - const cardUsageRows = - cardUsage.length > 0 - ? cardUsage - .map( - (item) => ` + const cardUsageRows = + cardUsage.length > 0 + ? cardUsage + .map( + (item) => ` ${escapeHtml( - item.name - )} + item.name, + )} ${formatCurrency( - item.amount - )} - ` - ) - .join("") - : `Sem gastos com cartão neste período.`; + item.amount, + )} + `, + ) + .join("") + : `Sem gastos com cartão neste período.`; - const boletoRows = - boletos.length > 0 - ? boletos - .map( - (item) => ` + const boletoRows = + boletos.length > 0 + ? boletos + .map( + (item) => ` ${escapeHtml( - item.name - )} + item.name, + )} ${ - item.dueDate ? formatDate(item.dueDate) : "—" - } + item.dueDate ? formatDate(item.dueDate) : "—" + } ${formatCurrency( - item.amount - )} - ` - ) - .join("") - : `Sem boletos neste período.`; + item.amount, + )} + `, + ) + .join("") + : `Sem boletos neste período.`; - const lancamentoRows = - lancamentos.length > 0 - ? lancamentos - .map( - (item) => ` + const lancamentoRows = + lancamentos.length > 0 + ? lancamentos + .map( + (item) => ` ${formatDate( - item.purchaseDate - )} + item.purchaseDate, + )} ${ - escapeHtml(item.name) || "Sem descrição" - } + escapeHtml(item.name) || "Sem descrição" + } ${ - escapeHtml(item.condition) || "—" - } + escapeHtml(item.condition) || "—" + } ${ - escapeHtml(item.paymentMethod) || "—" - } + escapeHtml(item.paymentMethod) || "—" + } ${formatCurrency( - item.amount - )} - ` - ) - .join("") - : `Nenhum lançamento registrado no período.`; + item.amount, + )} + `, + ) + .join("") + : `Nenhum lançamento registrado no período.`; - const parceladoRows = - parcelados.length > 0 - ? parcelados - .map( - (item) => ` + const parceladoRows = + parcelados.length > 0 + ? parcelados + .map( + (item) => ` ${formatDate( - item.purchaseDate - )} + item.purchaseDate, + )} ${ - escapeHtml(item.name) || "Sem descrição" - } + escapeHtml(item.name) || "Sem descrição" + } ${ - item.currentInstallment - }/${item.installmentCount} + item.currentInstallment + }/${item.installmentCount} ${formatCurrency( - item.installmentAmount - )} + item.installmentAmount, + )} ${formatCurrency( - item.totalAmount - )} - ` - ) - .join("") - : `Nenhum lançamento parcelado neste período.`; + item.totalAmount, + )} + `, + ) + .join("") + : `Nenhum lançamento parcelado neste período.`; - return ` + return `
Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos. @@ -237,8 +237,8 @@ const buildSummaryHtml = ({

Resumo Financeiro

${escapeHtml( - periodLabel - )}

+ periodLabel, + )}

@@ -246,8 +246,8 @@ const buildSummaryHtml = ({

Olá ${escapeHtml( - pagadorName - )}, segue o consolidado do mês: + pagadorName, + )}, segue o consolidado do mês:

@@ -258,27 +258,27 @@ const buildSummaryHtml = ({ Total gasto ${formatCurrency( - monthlyBreakdown.totalExpenses - )} + monthlyBreakdown.totalExpenses, + )} 💳 Cartões ${formatCurrency( - monthlyBreakdown.paymentSplits.card - )} + monthlyBreakdown.paymentSplits.card, + )} 📄 Boletos ${formatCurrency( - monthlyBreakdown.paymentSplits.boleto - )} + monthlyBreakdown.paymentSplits.boleto, + )} ⚡ Pix/Débito/Dinheiro ${formatCurrency( - monthlyBreakdown.paymentSplits.instant - )} + monthlyBreakdown.paymentSplits.instant, + )} @@ -305,8 +305,8 @@ const buildSummaryHtml = ({ Total ${formatCurrency( - monthlyBreakdown.paymentSplits.card - )} + monthlyBreakdown.paymentSplits.card, + )} @@ -333,8 +333,8 @@ const buildSummaryHtml = ({ Total ${formatCurrency( - boletoStats.totalAmount - )} + boletoStats.totalAmount, + )} @@ -396,207 +396,207 @@ const buildSummaryHtml = ({ }; export async function sendPagadorSummaryAction( - input: z.infer + input: z.infer, ): Promise { - try { - const { pagadorId, period } = inputSchema.parse(input); - const user = await getUser(); + try { + const { pagadorId, period } = inputSchema.parse(input); + const user = await getUser(); - const pagadorRow = await db.query.pagadores.findFirst({ - where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)), - }); + const pagadorRow = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, pagadorId), eq(pagadores.userId, user.id)), + }); - if (!pagadorRow) { - return { success: false, error: "Pagador não encontrado." }; - } + if (!pagadorRow) { + return { success: false, error: "Pagador não encontrado." }; + } - if (!pagadorRow.email) { - return { - success: false, - error: "Cadastre um e-mail para conseguir enviar o resumo.", - }; - } + if (!pagadorRow.email) { + return { + success: false, + error: "Cadastre um e-mail para conseguir enviar o resumo.", + }; + } - const resendApiKey = process.env.RESEND_API_KEY; - const resendFrom = - process.env.RESEND_FROM_EMAIL ?? "Opensheets "; + const resendApiKey = process.env.RESEND_API_KEY; + const resendFrom = + process.env.RESEND_FROM_EMAIL ?? "Opensheets "; - if (!resendApiKey) { - return { - success: false, - error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).", - }; - } + if (!resendApiKey) { + return { + success: false, + error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).", + }; + } - const resend = new Resend(resendApiKey); + const resend = new Resend(resendApiKey); - const [ - monthlyBreakdown, - historyData, - cardUsage, - boletoStats, - boletoRows, - lancamentoRows, - parceladoRows, - ] = await Promise.all([ - fetchPagadorMonthlyBreakdown({ - userId: user.id, - pagadorId, - period, - }), - fetchPagadorHistory({ - userId: user.id, - pagadorId, - period, - }), - fetchPagadorCardUsage({ - userId: user.id, - pagadorId, - period, - }), - fetchPagadorBoletoStats({ - userId: user.id, - pagadorId, - period, - }), - db - .select({ - name: lancamentos.name, - amount: lancamentos.amount, - dueDate: lancamentos.dueDate, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.pagadorId, pagadorId), - eq(lancamentos.period, period), - eq(lancamentos.paymentMethod, "Boleto") - ) - ) - .orderBy(desc(lancamentos.dueDate)), - db - .select({ - id: lancamentos.id, - name: lancamentos.name, - paymentMethod: lancamentos.paymentMethod, - condition: lancamentos.condition, - amount: lancamentos.amount, - transactionType: lancamentos.transactionType, - purchaseDate: lancamentos.purchaseDate, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.pagadorId, pagadorId), - eq(lancamentos.period, period) - ) - ) - .orderBy(desc(lancamentos.purchaseDate)), - db - .select({ - name: lancamentos.name, - amount: lancamentos.amount, - installmentCount: lancamentos.installmentCount, - currentInstallment: lancamentos.currentInstallment, - purchaseDate: lancamentos.purchaseDate, - }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, user.id), - eq(lancamentos.pagadorId, pagadorId), - eq(lancamentos.period, period), - eq(lancamentos.condition, "Parcelado"), - eq(lancamentos.isAnticipated, false) - ) - ) - .orderBy(desc(lancamentos.purchaseDate)), - ]); + const [ + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletoRows, + lancamentoRows, + parceladoRows, + ] = await Promise.all([ + fetchPagadorMonthlyBreakdown({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorHistory({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorCardUsage({ + userId: user.id, + pagadorId, + period, + }), + fetchPagadorBoletoStats({ + userId: user.id, + pagadorId, + period, + }), + db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + dueDate: lancamentos.dueDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period), + eq(lancamentos.paymentMethod, "Boleto"), + ), + ) + .orderBy(desc(lancamentos.dueDate)), + db + .select({ + id: lancamentos.id, + name: lancamentos.name, + paymentMethod: lancamentos.paymentMethod, + condition: lancamentos.condition, + amount: lancamentos.amount, + transactionType: lancamentos.transactionType, + purchaseDate: lancamentos.purchaseDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period), + ), + ) + .orderBy(desc(lancamentos.purchaseDate)), + db + .select({ + name: lancamentos.name, + amount: lancamentos.amount, + installmentCount: lancamentos.installmentCount, + currentInstallment: lancamentos.currentInstallment, + purchaseDate: lancamentos.purchaseDate, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + eq(lancamentos.pagadorId, pagadorId), + eq(lancamentos.period, period), + eq(lancamentos.condition, "Parcelado"), + eq(lancamentos.isAnticipated, false), + ), + ) + .orderBy(desc(lancamentos.purchaseDate)), + ]); - const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ - name: row.name ?? "Sem descrição", - amount: Math.abs(Number(row.amount ?? 0)), - dueDate: row.dueDate, - })); + const normalizedBoletos: BoletoItem[] = boletoRows.map((row) => ({ + name: row.name ?? "Sem descrição", + amount: Math.abs(Number(row.amount ?? 0)), + dueDate: row.dueDate, + })); - const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( - (row) => ({ - id: row.id, - name: row.name, - paymentMethod: row.paymentMethod, - condition: row.condition, - transactionType: row.transactionType, - purchaseDate: row.purchaseDate, - amount: Number(row.amount ?? 0), - }) - ); + const normalizedLancamentos: LancamentoRow[] = lancamentoRows.map( + (row) => ({ + id: row.id, + name: row.name, + paymentMethod: row.paymentMethod, + condition: row.condition, + transactionType: row.transactionType, + purchaseDate: row.purchaseDate, + amount: Number(row.amount ?? 0), + }), + ); - const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => { - const installmentAmount = Math.abs(Number(row.amount ?? 0)); - const installmentCount = row.installmentCount ?? 1; - const totalAmount = installmentAmount * installmentCount; + const normalizedParcelados: ParceladoItem[] = parceladoRows.map((row) => { + const installmentAmount = Math.abs(Number(row.amount ?? 0)); + const installmentCount = row.installmentCount ?? 1; + const totalAmount = installmentAmount * installmentCount; - return { - name: row.name ?? "Sem descrição", - installmentAmount, - installmentCount, - currentInstallment: row.currentInstallment ?? 1, - totalAmount, - purchaseDate: row.purchaseDate, - }; - }); + return { + name: row.name ?? "Sem descrição", + installmentAmount, + installmentCount, + currentInstallment: row.currentInstallment ?? 1, + totalAmount, + purchaseDate: row.purchaseDate, + }; + }); - const html = buildSummaryHtml({ - pagadorName: pagadorRow.name, - periodLabel: displayPeriod(period), - monthlyBreakdown, - historyData, - cardUsage, - boletoStats, - boletos: normalizedBoletos, - lancamentos: normalizedLancamentos, - parcelados: normalizedParcelados, - }); + const html = buildSummaryHtml({ + pagadorName: pagadorRow.name, + periodLabel: displayPeriod(period), + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + boletos: normalizedBoletos, + lancamentos: normalizedLancamentos, + parcelados: normalizedParcelados, + }); - await resend.emails.send({ - from: resendFrom, - to: pagadorRow.email, - subject: `Resumo Financeiro | ${displayPeriod(period)}`, - html, - }); + await resend.emails.send({ + from: resendFrom, + to: pagadorRow.email, + subject: `Resumo Financeiro | ${displayPeriod(period)}`, + html, + }); - const now = new Date(); + const now = new Date(); - await db - .update(pagadores) - .set({ lastMailAt: now }) - .where( - and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)) - ); + await db + .update(pagadores) + .set({ lastMailAt: now }) + .where( + and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.id)), + ); - revalidatePath(`/pagadores/${pagadorRow.id}`); + revalidatePath(`/pagadores/${pagadorRow.id}`); - return { success: true, message: "Resumo enviado com sucesso." }; - } catch (error) { - // Log estruturado em desenvolvimento - if (process.env.NODE_ENV === "development") { - console.error("[sendPagadorSummaryAction]", error); - } + return { success: true, message: "Resumo enviado com sucesso." }; + } catch (error) { + // Log estruturado em desenvolvimento + if (process.env.NODE_ENV === "development") { + console.error("[sendPagadorSummaryAction]", error); + } - // Tratar erros de validação separadamente - if (error instanceof z.ZodError) { - return { - success: false, - error: error.issues[0]?.message ?? "Dados inválidos.", - }; - } + // Tratar erros de validação separadamente + if (error instanceof z.ZodError) { + return { + success: false, + error: error.issues[0]?.message ?? "Dados inválidos.", + }; + } - // Não expor detalhes do erro para o usuário - return { - success: false, - error: "Não foi possível enviar o resumo. Tente novamente mais tarde.", - }; - } + // Não expor detalhes do erro para o usuário + return { + success: false, + error: "Não foi possível enviar o resumo. Tente novamente mais tarde.", + }; + } } diff --git a/app/(dashboard)/pagadores/[pagadorId]/data.ts b/app/(dashboard)/pagadores/[pagadorId]/data.ts index accbbbf..bed5a83 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/data.ts +++ b/app/(dashboard)/pagadores/[pagadorId]/data.ts @@ -1,90 +1,95 @@ -import { lancamentos, pagadorShares, user as usersTable, contas, cartoes, categorias, pagadores } from "@/db/schema"; -import { db } from "@/lib/db"; import { and, desc, eq, type SQL } from "drizzle-orm"; +import { + cartoes, + categorias, + contas, + lancamentos, + pagadores, + pagadorShares, + user as usersTable, +} from "@/db/schema"; +import { db } from "@/lib/db"; export type ShareData = { - id: string; - userId: string; - name: string; - email: string; - createdAt: string; + id: string; + userId: string; + name: string; + email: string; + createdAt: string; }; export async function fetchPagadorShares( - pagadorId: string + pagadorId: string, ): Promise { - const shareRows = await db - .select({ - id: pagadorShares.id, - sharedWithUserId: pagadorShares.sharedWithUserId, - createdAt: pagadorShares.createdAt, - userName: usersTable.name, - userEmail: usersTable.email, - }) - .from(pagadorShares) - .innerJoin( - usersTable, - eq(pagadorShares.sharedWithUserId, usersTable.id) - ) - .where(eq(pagadorShares.pagadorId, pagadorId)); + const shareRows = await db + .select({ + id: pagadorShares.id, + sharedWithUserId: pagadorShares.sharedWithUserId, + createdAt: pagadorShares.createdAt, + userName: usersTable.name, + userEmail: usersTable.email, + }) + .from(pagadorShares) + .innerJoin(usersTable, eq(pagadorShares.sharedWithUserId, usersTable.id)) + .where(eq(pagadorShares.pagadorId, pagadorId)); - return shareRows.map((share) => ({ - id: share.id, - userId: share.sharedWithUserId, - name: share.userName ?? "Usuário", - email: share.userEmail ?? "email não informado", - createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(), - })); + return shareRows.map((share) => ({ + id: share.id, + userId: share.sharedWithUserId, + name: share.userName ?? "Usuário", + email: share.userEmail ?? "email não informado", + createdAt: share.createdAt?.toISOString() ?? new Date().toISOString(), + })); } export async function fetchCurrentUserShare( - pagadorId: string, - userId: string + pagadorId: string, + userId: string, ): Promise<{ id: string; createdAt: string } | null> { - const shareRow = await db.query.pagadorShares.findFirst({ - columns: { - id: true, - createdAt: true, - }, - where: and( - eq(pagadorShares.pagadorId, pagadorId), - eq(pagadorShares.sharedWithUserId, userId) - ), - }); + const shareRow = await db.query.pagadorShares.findFirst({ + columns: { + id: true, + createdAt: true, + }, + where: and( + eq(pagadorShares.pagadorId, pagadorId), + eq(pagadorShares.sharedWithUserId, userId), + ), + }); - if (!shareRow) { - return null; - } + if (!shareRow) { + return null; + } - return { - id: shareRow.id, - createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(), - }; + return { + id: shareRow.id, + createdAt: shareRow.createdAt?.toISOString() ?? new Date().toISOString(), + }; } export async function fetchPagadorLancamentos(filters: SQL[]) { - const lancamentoRows = await db - .select({ - lancamento: lancamentos, - pagador: pagadores, - conta: contas, - cartao: cartoes, - categoria: categorias, - }) - .from(lancamentos) - .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) - .leftJoin(contas, eq(lancamentos.contaId, contas.id)) - .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) - .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) - .where(and(...filters)) - .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); + const lancamentoRows = await db + .select({ + lancamento: lancamentos, + pagador: pagadores, + conta: contas, + cartao: cartoes, + categoria: categorias, + }) + .from(lancamentos) + .leftJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .leftJoin(contas, eq(lancamentos.contaId, contas.id)) + .leftJoin(cartoes, eq(lancamentos.cartaoId, cartoes.id)) + .leftJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where(and(...filters)) + .orderBy(desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)); - // Transformar resultado para o formato esperado - return lancamentoRows.map((row: any) => ({ - ...row.lancamento, - pagador: row.pagador, - conta: row.conta, - cartao: row.cartao, - categoria: row.categoria, - })); + // Transformar resultado para o formato esperado + return lancamentoRows.map((row: any) => ({ + ...row.lancamento, + pagador: row.pagador, + conta: row.conta, + cartao: row.cartao, + categoria: row.categoria, + })); } diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx index 555c0dc..3623965 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx @@ -5,80 +5,80 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos) */ export default function PagadorDetailsLoading() { - return ( -
- {/* Month Picker placeholder */} -
+ return ( +
+ {/* Month Picker placeholder */} +
- {/* Info do Pagador (sempre visível) */} -
-
- {/* Avatar */} - + {/* Info do Pagador (sempre visível) */} +
+
+ {/* Avatar */} + -
- {/* Nome + Badge */} -
- - -
+
+ {/* Nome + Badge */} +
+ + +
- {/* Email */} - + {/* Email */} + - {/* Status */} -
- - -
-
+ {/* Status */} +
+ + +
+
- {/* Botões de ação */} -
- - -
-
-
+ {/* Botões de ação */} +
+ + +
+
+
- {/* Tabs */} -
-
- - -
+ {/* Tabs */} +
+
+ + +
- {/* Conteúdo da aba Visão Geral (grid de cards) */} -
- {/* Card de resumo mensal */} -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - -
- ))} -
-
+ {/* Conteúdo da aba Visão Geral (grid de cards) */} +
+ {/* Card de resumo mensal */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
- {/* Outros cards */} - {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
-
- - - -
-
- ))} -
-
-
- ); + {/* Outros cards */} + {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+
+ + ); } diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx index 2d587ce..462ba81 100644 --- a/app/(dashboard)/pagadores/[pagadorId]/page.tsx +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -1,435 +1,443 @@ +import { notFound } from "next/navigation"; import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions"; +import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; +import type { + ContaCartaoFilterOption, + LancamentoFilterOption, + LancamentoItem, + SelectOption, +} from "@/components/lancamentos/types"; +import MonthNavigation from "@/components/month-picker/month-navigation"; import { PagadorCardUsageCard } from "@/components/pagadores/details/pagador-card-usage-card"; import { PagadorHistoryCard } from "@/components/pagadores/details/pagador-history-card"; import { PagadorInfoCard } from "@/components/pagadores/details/pagador-info-card"; +import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card"; import { PagadorMonthlySummaryCard } from "@/components/pagadores/details/pagador-monthly-summary-card"; import { PagadorBoletoCard } from "@/components/pagadores/details/pagador-payment-method-cards"; import { PagadorSharingCard } from "@/components/pagadores/details/pagador-sharing-card"; -import { PagadorLeaveShareCard } from "@/components/pagadores/details/pagador-leave-share-card"; -import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page"; -import type { - ContaCartaoFilterOption, - LancamentoFilterOption, - LancamentoItem, - SelectOption, -} from "@/components/lancamentos/types"; -import MonthNavigation from "@/components/month-picker/month-navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { pagadores } from "@/db/schema"; +import type { pagadores } from "@/db/schema"; import { getUserId } from "@/lib/auth/server"; import { - buildLancamentoWhere, - buildOptionSets, - buildSluggedFilters, - buildSlugMaps, - extractLancamentoSearchFilters, - fetchLancamentoFilterSources, - getSingleParam, - mapLancamentosData, - type LancamentoSearchFilters, - type ResolvedSearchParams, - type SlugMaps, - type SluggedFilters, + buildLancamentoWhere, + buildOptionSets, + buildSluggedFilters, + buildSlugMaps, + extractLancamentoSearchFilters, + fetchLancamentoFilterSources, + getSingleParam, + type LancamentoSearchFilters, + mapLancamentosData, + type ResolvedSearchParams, + type SluggedFilters, + type SlugMaps, } from "@/lib/lancamentos/page-helpers"; import { getPagadorAccess } from "@/lib/pagadores/access"; +import { + fetchPagadorBoletoStats, + fetchPagadorCardUsage, + fetchPagadorHistory, + fetchPagadorMonthlyBreakdown, +} from "@/lib/pagadores/details"; import { parsePeriodParam } from "@/lib/utils/period"; import { - fetchPagadorBoletoStats, - fetchPagadorCardUsage, - fetchPagadorHistory, - fetchPagadorMonthlyBreakdown, -} from "@/lib/pagadores/details"; -import { notFound } from "next/navigation"; -import { fetchPagadorLancamentos, fetchPagadorShares, fetchCurrentUserShare } from "./data"; + fetchCurrentUserShare, + fetchPagadorLancamentos, + fetchPagadorShares, +} from "./data"; type PageSearchParams = Promise; type PageProps = { - params: Promise<{ pagadorId: string }>; - searchParams?: PageSearchParams; + params: Promise<{ pagadorId: string }>; + searchParams?: PageSearchParams; }; const capitalize = (value: string) => - value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value; + value.length ? value.charAt(0).toUpperCase().concat(value.slice(1)) : value; const EMPTY_FILTERS: LancamentoSearchFilters = { - transactionFilter: null, - conditionFilter: null, - paymentFilter: null, - pagadorFilter: null, - categoriaFilter: null, - contaCartaoFilter: null, - searchFilter: null, + transactionFilter: null, + conditionFilter: null, + paymentFilter: null, + pagadorFilter: null, + categoriaFilter: null, + contaCartaoFilter: null, + searchFilter: null, }; const createEmptySlugMaps = (): SlugMaps => ({ - pagador: new Map(), - categoria: new Map(), - conta: new Map(), - cartao: new Map(), + pagador: new Map(), + categoria: new Map(), + conta: new Map(), + cartao: new Map(), }); type OptionSet = ReturnType; export default async function Page({ params, searchParams }: PageProps) { - const { pagadorId } = await params; - const userId = await getUserId(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; + const { pagadorId } = await params; + const userId = await getUserId(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; - const access = await getPagadorAccess(userId, pagadorId); + const access = await getPagadorAccess(userId, pagadorId); - if (!access) { - notFound(); - } + if (!access) { + notFound(); + } - const { pagador, canEdit } = access; - const dataOwnerId = pagador.userId; + const { pagador, canEdit } = access; + const dataOwnerId = pagador.userId; - const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); - const { - period: selectedPeriod, - monthName, - year, - } = parsePeriodParam(periodoParamRaw); - const periodLabel = `${capitalize(monthName)} de ${year}`; + const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo"); + const { + period: selectedPeriod, + monthName, + year, + } = parsePeriodParam(periodoParamRaw); + const periodLabel = `${capitalize(monthName)} de ${year}`; - const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams); - const searchFilters = canEdit - ? allSearchFilters - : { - ...EMPTY_FILTERS, - searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only - }; + const allSearchFilters = extractLancamentoSearchFilters(resolvedSearchParams); + const searchFilters = canEdit + ? allSearchFilters + : { + ...EMPTY_FILTERS, + searchFilter: allSearchFilters.searchFilter, // Permitir busca mesmo em modo read-only + }; - let filterSources: Awaited< - ReturnType - > | null = null; - let loggedUserFilterSources: Awaited< - ReturnType - > | null = null; - let sluggedFilters: SluggedFilters; - let slugMaps: SlugMaps; + let filterSources: Awaited< + ReturnType + > | null = null; + let loggedUserFilterSources: Awaited< + ReturnType + > | null = null; + let sluggedFilters: SluggedFilters; + let slugMaps: SlugMaps; - if (canEdit) { - filterSources = await fetchLancamentoFilterSources(dataOwnerId); - sluggedFilters = buildSluggedFilters(filterSources); - slugMaps = buildSlugMaps(sluggedFilters); - } else { - // Buscar opções do usuário logado para usar ao importar - loggedUserFilterSources = await fetchLancamentoFilterSources(userId); - sluggedFilters = { - pagadorFiltersRaw: [], - categoriaFiltersRaw: [], - contaFiltersRaw: [], - cartaoFiltersRaw: [], - }; - slugMaps = createEmptySlugMaps(); - } + if (canEdit) { + filterSources = await fetchLancamentoFilterSources(dataOwnerId); + sluggedFilters = buildSluggedFilters(filterSources); + slugMaps = buildSlugMaps(sluggedFilters); + } else { + // Buscar opções do usuário logado para usar ao importar + loggedUserFilterSources = await fetchLancamentoFilterSources(userId); + sluggedFilters = { + pagadorFiltersRaw: [], + categoriaFiltersRaw: [], + contaFiltersRaw: [], + cartaoFiltersRaw: [], + }; + slugMaps = createEmptySlugMaps(); + } - const filters = buildLancamentoWhere({ - userId: dataOwnerId, - period: selectedPeriod, - filters: searchFilters, - slugMaps, - pagadorId: pagador.id, - }); + const filters = buildLancamentoWhere({ + userId: dataOwnerId, + period: selectedPeriod, + filters: searchFilters, + slugMaps, + pagadorId: pagador.id, + }); - const sharesPromise = canEdit - ? fetchPagadorShares(pagador.id) - : Promise.resolve([]); + const sharesPromise = canEdit + ? fetchPagadorShares(pagador.id) + : Promise.resolve([]); - const currentUserSharePromise = !canEdit - ? fetchCurrentUserShare(pagador.id, userId) - : Promise.resolve(null); + const currentUserSharePromise = !canEdit + ? fetchCurrentUserShare(pagador.id, userId) + : Promise.resolve(null); - const [ - lancamentoRows, - monthlyBreakdown, - historyData, - cardUsage, - boletoStats, - shareRows, - currentUserShare, - estabelecimentos, - ] = await Promise.all([ - fetchPagadorLancamentos(filters), - fetchPagadorMonthlyBreakdown({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorHistory({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorCardUsage({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - fetchPagadorBoletoStats({ - userId: dataOwnerId, - pagadorId: pagador.id, - period: selectedPeriod, - }), - sharesPromise, - currentUserSharePromise, - getRecentEstablishmentsAction(), - ]); + const [ + lancamentoRows, + monthlyBreakdown, + historyData, + cardUsage, + boletoStats, + shareRows, + currentUserShare, + estabelecimentos, + ] = await Promise.all([ + fetchPagadorLancamentos(filters), + fetchPagadorMonthlyBreakdown({ + userId: dataOwnerId, + pagadorId: pagador.id, + period: selectedPeriod, + }), + fetchPagadorHistory({ + userId: dataOwnerId, + pagadorId: pagador.id, + period: selectedPeriod, + }), + fetchPagadorCardUsage({ + userId: dataOwnerId, + pagadorId: pagador.id, + period: selectedPeriod, + }), + fetchPagadorBoletoStats({ + userId: dataOwnerId, + pagadorId: pagador.id, + period: selectedPeriod, + }), + sharesPromise, + currentUserSharePromise, + getRecentEstablishmentsAction(), + ]); - const mappedLancamentos = mapLancamentosData(lancamentoRows); - const lancamentosData = canEdit - ? mappedLancamentos - : mappedLancamentos.map((item) => ({ ...item, readonly: true })); + const mappedLancamentos = mapLancamentosData(lancamentoRows); + const lancamentosData = canEdit + ? mappedLancamentos + : mappedLancamentos.map((item) => ({ ...item, readonly: true })); - const pagadorSharesData = shareRows; + const pagadorSharesData = shareRows; - let optionSets: OptionSet; - let loggedUserOptionSets: OptionSet | null = null; - let effectiveSluggedFilters = sluggedFilters; + let optionSets: OptionSet; + let loggedUserOptionSets: OptionSet | null = null; + let effectiveSluggedFilters = sluggedFilters; - if (canEdit && filterSources) { - optionSets = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); - } else { - effectiveSluggedFilters = { - pagadorFiltersRaw: [ - { - id: pagador.id, - label: pagador.name, - slug: pagador.id, - role: pagador.role, - }, - ], - categoriaFiltersRaw: [], - contaFiltersRaw: [], - cartaoFiltersRaw: [], - }; - optionSets = buildReadOnlyOptionSets(lancamentosData, pagador); + if (canEdit && filterSources) { + optionSets = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); + } else { + effectiveSluggedFilters = { + pagadorFiltersRaw: [ + { + id: pagador.id, + label: pagador.name, + slug: pagador.id, + role: pagador.role, + }, + ], + categoriaFiltersRaw: [], + contaFiltersRaw: [], + cartaoFiltersRaw: [], + }; + optionSets = buildReadOnlyOptionSets(lancamentosData, pagador); - // Construir opções do usuário logado para usar ao importar - if (loggedUserFilterSources) { - const loggedUserSluggedFilters = buildSluggedFilters(loggedUserFilterSources); - loggedUserOptionSets = buildOptionSets({ - ...loggedUserSluggedFilters, - pagadorRows: loggedUserFilterSources.pagadorRows, - }); - } - } + // Construir opções do usuário logado para usar ao importar + if (loggedUserFilterSources) { + const loggedUserSluggedFilters = buildSluggedFilters( + loggedUserFilterSources, + ); + loggedUserOptionSets = buildOptionSets({ + ...loggedUserSluggedFilters, + pagadorRows: loggedUserFilterSources.pagadorRows, + }); + } + } - const pagadorSlug = - effectiveSluggedFilters.pagadorFiltersRaw.find( - (item) => item.id === pagador.id - )?.slug ?? null; + const pagadorSlug = + effectiveSluggedFilters.pagadorFiltersRaw.find( + (item) => item.id === pagador.id, + )?.slug ?? null; - const pagadorFilterOptions = pagadorSlug - ? optionSets.pagadorFilterOptions.filter( - (option) => option.slug === pagadorSlug - ) - : optionSets.pagadorFilterOptions; + const pagadorFilterOptions = pagadorSlug + ? optionSets.pagadorFilterOptions.filter( + (option) => option.slug === pagadorSlug, + ) + : optionSets.pagadorFilterOptions; - const pagadorData = { - id: pagador.id, - name: pagador.name, - email: pagador.email ?? null, - avatarUrl: pagador.avatarUrl ?? null, - status: pagador.status, - note: pagador.note ?? null, - role: pagador.role ?? null, - isAutoSend: pagador.isAutoSend ?? false, - createdAt: pagador.createdAt - ? pagador.createdAt.toISOString() - : new Date().toISOString(), - lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null, - shareCode: canEdit ? pagador.shareCode : null, - canEdit, - }; + const pagadorData = { + id: pagador.id, + name: pagador.name, + email: pagador.email ?? null, + avatarUrl: pagador.avatarUrl ?? null, + status: pagador.status, + note: pagador.note ?? null, + role: pagador.role ?? null, + isAutoSend: pagador.isAutoSend ?? false, + createdAt: pagador.createdAt + ? pagador.createdAt.toISOString() + : new Date().toISOString(), + lastMailAt: pagador.lastMailAt ? pagador.lastMailAt.toISOString() : null, + shareCode: canEdit ? pagador.shareCode : null, + canEdit, + }; - const summaryPreview = { - periodLabel, - totalExpenses: monthlyBreakdown.totalExpenses, - paymentSplits: monthlyBreakdown.paymentSplits, - cardUsage: cardUsage.slice(0, 3).map((item) => ({ - name: item.name, - amount: item.amount, - })), - boletoStats: { - totalAmount: boletoStats.totalAmount, - paidAmount: boletoStats.paidAmount, - pendingAmount: boletoStats.pendingAmount, - paidCount: boletoStats.paidCount, - pendingCount: boletoStats.pendingCount, - }, - lancamentoCount: lancamentosData.length, - }; + const summaryPreview = { + periodLabel, + totalExpenses: monthlyBreakdown.totalExpenses, + paymentSplits: monthlyBreakdown.paymentSplits, + cardUsage: cardUsage.slice(0, 3).map((item) => ({ + name: item.name, + amount: item.amount, + })), + boletoStats: { + totalAmount: boletoStats.totalAmount, + paidAmount: boletoStats.paidAmount, + pendingAmount: boletoStats.pendingAmount, + paidCount: boletoStats.paidCount, + pendingCount: boletoStats.pendingCount, + }, + lancamentoCount: lancamentosData.length, + }; - return ( -
- + return ( +
+ - - - Perfil - Painel - Lançamentos - + + + Perfil + Painel + Lançamentos + - -
- -
- {canEdit && pagadorData.shareCode ? ( - - ) : null} - {!canEdit && currentUserShare ? ( - - ) : null} -
+ +
+ +
+ {canEdit && pagadorData.shareCode ? ( + + ) : null} + {!canEdit && currentUserShare ? ( + + ) : null} +
- -
- - -
+ +
+ + +
-
- - -
-
+
+ + +
+
- -
- -
-
-
-
- ); + +
+ +
+
+ +
+ ); } const normalizeOptionLabel = ( - value: string | null | undefined, - fallback: string + value: string | null | undefined, + fallback: string, ) => (value?.trim().length ? value.trim() : fallback); function buildReadOnlyOptionSets( - items: LancamentoItem[], - pagador: typeof pagadores.$inferSelect + items: LancamentoItem[], + pagador: typeof pagadores.$inferSelect, ): OptionSet { - const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador"); - const pagadorOptions: SelectOption[] = [ - { - value: pagador.id, - label: pagadorLabel, - slug: pagador.id, - }, - ]; + const pagadorLabel = normalizeOptionLabel(pagador.name, "Pagador"); + const pagadorOptions: SelectOption[] = [ + { + value: pagador.id, + label: pagadorLabel, + slug: pagador.id, + }, + ]; - const contaOptionsMap = new Map(); - const cartaoOptionsMap = new Map(); - const categoriaOptionsMap = new Map(); + const contaOptionsMap = new Map(); + const cartaoOptionsMap = new Map(); + const categoriaOptionsMap = new Map(); - items.forEach((item) => { - if (item.contaId && !contaOptionsMap.has(item.contaId)) { - contaOptionsMap.set(item.contaId, { - value: item.contaId, - label: normalizeOptionLabel(item.contaName, "Conta sem nome"), - slug: item.contaId, - }); - } - if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) { - cartaoOptionsMap.set(item.cartaoId, { - value: item.cartaoId, - label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"), - slug: item.cartaoId, - }); - } - if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) { - categoriaOptionsMap.set(item.categoriaId, { - value: item.categoriaId, - label: normalizeOptionLabel(item.categoriaName, "Categoria"), - slug: item.categoriaId, - }); - } - }); + items.forEach((item) => { + if (item.contaId && !contaOptionsMap.has(item.contaId)) { + contaOptionsMap.set(item.contaId, { + value: item.contaId, + label: normalizeOptionLabel(item.contaName, "Conta sem nome"), + slug: item.contaId, + }); + } + if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) { + cartaoOptionsMap.set(item.cartaoId, { + value: item.cartaoId, + label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"), + slug: item.cartaoId, + }); + } + if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) { + categoriaOptionsMap.set(item.categoriaId, { + value: item.categoriaId, + label: normalizeOptionLabel(item.categoriaName, "Categoria"), + slug: item.categoriaId, + }); + } + }); - const contaOptions = Array.from(contaOptionsMap.values()); - const cartaoOptions = Array.from(cartaoOptionsMap.values()); - const categoriaOptions = Array.from(categoriaOptionsMap.values()); + const contaOptions = Array.from(contaOptionsMap.values()); + const cartaoOptions = Array.from(cartaoOptionsMap.values()); + const categoriaOptions = Array.from(categoriaOptionsMap.values()); - const pagadorFilterOptions: LancamentoFilterOption[] = [ - { slug: pagador.id, label: pagadorLabel }, - ]; + const pagadorFilterOptions: LancamentoFilterOption[] = [ + { slug: pagador.id, label: pagadorLabel }, + ]; - const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map( - (option) => ({ - slug: option.value, - label: option.label, - }) - ); + const categoriaFilterOptions: LancamentoFilterOption[] = categoriaOptions.map( + (option) => ({ + slug: option.value, + label: option.label, + }), + ); - const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [ - ...contaOptions.map((option) => ({ - slug: option.value, - label: option.label, - kind: "conta" as const, - })), - ...cartaoOptions.map((option) => ({ - slug: option.value, - label: option.label, - kind: "cartao" as const, - })), - ]; + const contaCartaoFilterOptions: ContaCartaoFilterOption[] = [ + ...contaOptions.map((option) => ({ + slug: option.value, + label: option.label, + kind: "conta" as const, + })), + ...cartaoOptions.map((option) => ({ + slug: option.value, + label: option.label, + kind: "cartao" as const, + })), + ]; - return { - pagadorOptions, - splitPagadorOptions: [], - defaultPagadorId: pagador.id, - contaOptions, - cartaoOptions, - categoriaOptions, - pagadorFilterOptions, - categoriaFilterOptions, - contaCartaoFilterOptions, - }; + return { + pagadorOptions, + splitPagadorOptions: [], + defaultPagadorId: pagador.id, + contaOptions, + cartaoOptions, + categoriaOptions, + pagadorFilterOptions, + categoriaFilterOptions, + contaCartaoFilterOptions, + }; } diff --git a/app/(dashboard)/pagadores/actions.ts b/app/(dashboard)/pagadores/actions.ts index 25b77dc..f7a1b08 100644 --- a/app/(dashboard)/pagadores/actions.ts +++ b/app/(dashboard)/pagadores/actions.ts @@ -1,70 +1,70 @@ "use server"; +import { randomBytes } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { pagadores, pagadorShares, user } from "@/db/schema"; import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; import type { ActionResult } from "@/lib/actions/types"; -import { db } from "@/lib/db"; import { getUser } from "@/lib/auth/server"; +import { db } from "@/lib/db"; import { - DEFAULT_PAGADOR_AVATAR, - PAGADOR_ROLE_ADMIN, - PAGADOR_ROLE_TERCEIRO, - PAGADOR_STATUS_OPTIONS, + DEFAULT_PAGADOR_AVATAR, + PAGADOR_ROLE_ADMIN, + PAGADOR_ROLE_TERCEIRO, + PAGADOR_STATUS_OPTIONS, } from "@/lib/pagadores/constants"; import { normalizeAvatarPath } from "@/lib/pagadores/utils"; import { noteSchema, uuidSchema } from "@/lib/schemas/common"; import { normalizeOptionalString } from "@/lib/utils/string"; -import { and, eq } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { randomBytes } from "node:crypto"; -import { z } from "zod"; const statusEnum = z.enum(PAGADOR_STATUS_OPTIONS as [string, ...string[]], { - errorMap: () => ({ - message: "Selecione um status válido.", - }), + errorMap: () => ({ + message: "Selecione um status válido.", + }), }); const baseSchema = z.object({ - name: z - .string({ message: "Informe o nome do pagador." }) - .trim() - .min(1, "Informe o nome do pagador."), - email: z - .string() - .trim() - .email("Informe um e-mail válido.") - .optional() - .transform((value) => normalizeOptionalString(value)), - status: statusEnum, - note: noteSchema, - avatarUrl: z.string().trim().optional(), - isAutoSend: z.boolean().optional().default(false), + name: z + .string({ message: "Informe o nome do pagador." }) + .trim() + .min(1, "Informe o nome do pagador."), + email: z + .string() + .trim() + .email("Informe um e-mail válido.") + .optional() + .transform((value) => normalizeOptionalString(value)), + status: statusEnum, + note: noteSchema, + avatarUrl: z.string().trim().optional(), + isAutoSend: z.boolean().optional().default(false), }); const createSchema = baseSchema; const updateSchema = baseSchema.extend({ - id: uuidSchema("Pagador"), + id: uuidSchema("Pagador"), }); const deleteSchema = z.object({ - id: uuidSchema("Pagador"), + id: uuidSchema("Pagador"), }); const shareDeleteSchema = z.object({ - shareId: uuidSchema("Compartilhamento"), + shareId: uuidSchema("Compartilhamento"), }); const shareCodeJoinSchema = z.object({ - code: z - .string({ message: "Informe o código." }) - .trim() - .min(8, "Código inválido."), + code: z + .string({ message: "Informe o código." }) + .trim() + .min(8, "Código inválido."), }); const shareCodeRegenerateSchema = z.object({ - pagadorId: uuidSchema("Pagador"), + pagadorId: uuidSchema("Pagador"), }); type CreateInput = z.infer; @@ -77,271 +77,286 @@ type ShareCodeRegenerateInput = z.infer; const revalidate = () => revalidateForEntity("pagadores"); const generateShareCode = () => { - // base64url já retorna apenas [a-zA-Z0-9_-] - // 18 bytes = 24 caracteres em base64 - return randomBytes(18).toString("base64url").slice(0, 24); + // base64url já retorna apenas [a-zA-Z0-9_-] + // 18 bytes = 24 caracteres em base64 + return randomBytes(18).toString("base64url").slice(0, 24); }; export async function createPagadorAction( - input: CreateInput + input: CreateInput, ): Promise { - try { - const user = await getUser(); - const data = createSchema.parse(input); + try { + const user = await getUser(); + const data = createSchema.parse(input); - await db.insert(pagadores).values({ - name: data.name, - email: data.email, - status: data.status, - note: data.note, - avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR, - isAutoSend: data.isAutoSend ?? false, - role: PAGADOR_ROLE_TERCEIRO, - shareCode: generateShareCode(), - userId: user.id, - }); + await db.insert(pagadores).values({ + name: data.name, + email: data.email, + status: data.status, + note: data.note, + avatarUrl: normalizeAvatarPath(data.avatarUrl) ?? DEFAULT_PAGADOR_AVATAR, + isAutoSend: data.isAutoSend ?? false, + role: PAGADOR_ROLE_TERCEIRO, + shareCode: generateShareCode(), + userId: user.id, + }); - revalidate(); + revalidate(); - return { success: true, message: "Pagador criado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Pagador criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function updatePagadorAction( - input: UpdateInput + input: UpdateInput, ): Promise { - try { - const currentUser = await getUser(); - const data = updateSchema.parse(input); + try { + const currentUser = await getUser(); + const data = updateSchema.parse(input); - const existing = await db.query.pagadores.findFirst({ - where: and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)), - }); + const existing = await db.query.pagadores.findFirst({ + where: and( + eq(pagadores.id, data.id), + eq(pagadores.userId, currentUser.id), + ), + }); - if (!existing) { - return { - success: false, - error: "Pagador não encontrado.", - }; - } + if (!existing) { + return { + success: false, + error: "Pagador não encontrado.", + }; + } - await db - .update(pagadores) - .set({ - name: data.name, - email: data.email, - status: data.status, - note: data.note, - avatarUrl: - normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null, - isAutoSend: data.isAutoSend ?? false, - role: existing.role ?? PAGADOR_ROLE_TERCEIRO, - }) - .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id))); + await db + .update(pagadores) + .set({ + name: data.name, + email: data.email, + status: data.status, + note: data.note, + avatarUrl: + normalizeAvatarPath(data.avatarUrl) ?? existing.avatarUrl ?? null, + isAutoSend: data.isAutoSend ?? false, + role: existing.role ?? PAGADOR_ROLE_TERCEIRO, + }) + .where( + and(eq(pagadores.id, data.id), eq(pagadores.userId, currentUser.id)), + ); - // Se o pagador é admin, sincronizar nome com o usuário - if (existing.role === PAGADOR_ROLE_ADMIN) { - await db - .update(user) - .set({ name: data.name }) - .where(eq(user.id, currentUser.id)); + // Se o pagador é admin, sincronizar nome com o usuário + if (existing.role === PAGADOR_ROLE_ADMIN) { + await db + .update(user) + .set({ name: data.name }) + .where(eq(user.id, currentUser.id)); - revalidatePath("/", "layout"); - } + revalidatePath("/", "layout"); + } - revalidate(); + revalidate(); - return { success: true, message: "Pagador atualizado com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Pagador atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function deletePagadorAction( - input: DeleteInput + input: DeleteInput, ): Promise { - try { - const user = await getUser(); - const data = deleteSchema.parse(input); + try { + const user = await getUser(); + const data = deleteSchema.parse(input); - const existing = await db.query.pagadores.findFirst({ - where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)), - }); + const existing = await db.query.pagadores.findFirst({ + where: and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)), + }); - if (!existing) { - return { - success: false, - error: "Pagador não encontrado.", - }; - } + if (!existing) { + return { + success: false, + error: "Pagador não encontrado.", + }; + } - if (existing.role === PAGADOR_ROLE_ADMIN) { - return { - success: false, - error: "Pagadores administradores não podem ser removidos.", - }; - } + if (existing.role === PAGADOR_ROLE_ADMIN) { + return { + success: false, + error: "Pagadores administradores não podem ser removidos.", + }; + } - await db - .delete(pagadores) - .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id))); + await db + .delete(pagadores) + .where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id))); - revalidate(); + revalidate(); - return { success: true, message: "Pagador removido com sucesso." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Pagador removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } } export async function joinPagadorByShareCodeAction( - input: ShareCodeJoinInput + input: ShareCodeJoinInput, ): Promise { - try { - const user = await getUser(); - const data = shareCodeJoinSchema.parse(input); + try { + const user = await getUser(); + const data = shareCodeJoinSchema.parse(input); - const pagadorRow = await db.query.pagadores.findFirst({ - where: eq(pagadores.shareCode, data.code), - }); + const pagadorRow = await db.query.pagadores.findFirst({ + where: eq(pagadores.shareCode, data.code), + }); - if (!pagadorRow) { - return { success: false, error: "Código inválido ou expirado." }; - } + if (!pagadorRow) { + return { success: false, error: "Código inválido ou expirado." }; + } - if (pagadorRow.userId === user.id) { - return { - success: false, - error: "Você já é o proprietário deste pagador.", - }; - } + if (pagadorRow.userId === user.id) { + return { + success: false, + error: "Você já é o proprietário deste pagador.", + }; + } - const existingShare = await db.query.pagadorShares.findFirst({ - where: and( - eq(pagadorShares.pagadorId, pagadorRow.id), - eq(pagadorShares.sharedWithUserId, user.id) - ), - }); + const existingShare = await db.query.pagadorShares.findFirst({ + where: and( + eq(pagadorShares.pagadorId, pagadorRow.id), + eq(pagadorShares.sharedWithUserId, user.id), + ), + }); - if (existingShare) { - return { - success: false, - error: "Você já possui acesso a este pagador.", - }; - } + if (existingShare) { + return { + success: false, + error: "Você já possui acesso a este pagador.", + }; + } - await db.insert(pagadorShares).values({ - pagadorId: pagadorRow.id, - sharedWithUserId: user.id, - permission: "read", - createdByUserId: pagadorRow.userId, - }); + await db.insert(pagadorShares).values({ + pagadorId: pagadorRow.id, + sharedWithUserId: user.id, + permission: "read", + createdByUserId: pagadorRow.userId, + }); - revalidate(); + revalidate(); - return { success: true, message: "Pagador adicionado à sua lista." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Pagador adicionado à sua lista." }; + } catch (error) { + return handleActionError(error); + } } export async function deletePagadorShareAction( - input: ShareDeleteInput + input: ShareDeleteInput, ): Promise { - try { - const user = await getUser(); - const data = shareDeleteSchema.parse(input); + try { + const user = await getUser(); + const data = shareDeleteSchema.parse(input); - const existing = await db.query.pagadorShares.findFirst({ - columns: { - id: true, - pagadorId: true, - sharedWithUserId: true, - }, - where: eq(pagadorShares.id, data.shareId), - with: { - pagador: { - columns: { - userId: true, - }, - }, - }, - }); + const existing = await db.query.pagadorShares.findFirst({ + columns: { + id: true, + pagadorId: true, + sharedWithUserId: true, + }, + where: eq(pagadorShares.id, data.shareId), + with: { + pagador: { + columns: { + userId: true, + }, + }, + }, + }); - // Permitir que o owner OU o próprio usuário compartilhado remova o share - if (!existing || (existing.pagador.userId !== user.id && existing.sharedWithUserId !== user.id)) { - return { - success: false, - error: "Compartilhamento não encontrado.", - }; - } + // Permitir que o owner OU o próprio usuário compartilhado remova o share + if ( + !existing || + (existing.pagador.userId !== user.id && + existing.sharedWithUserId !== user.id) + ) { + return { + success: false, + error: "Compartilhamento não encontrado.", + }; + } - await db - .delete(pagadorShares) - .where(eq(pagadorShares.id, data.shareId)); + await db.delete(pagadorShares).where(eq(pagadorShares.id, data.shareId)); - revalidate(); - revalidatePath(`/pagadores/${existing.pagadorId}`); + revalidate(); + revalidatePath(`/pagadores/${existing.pagadorId}`); - return { success: true, message: "Compartilhamento removido." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Compartilhamento removido." }; + } catch (error) { + return handleActionError(error); + } } export async function regeneratePagadorShareCodeAction( - input: ShareCodeRegenerateInput + input: ShareCodeRegenerateInput, ): Promise<{ success: true; message: string; code: string } | ActionResult> { - try { - const user = await getUser(); - const data = shareCodeRegenerateSchema.parse(input); + try { + const user = await getUser(); + const data = shareCodeRegenerateSchema.parse(input); - const existing = await db.query.pagadores.findFirst({ - columns: { id: true, userId: true }, - where: and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id)), - }); + const existing = await db.query.pagadores.findFirst({ + columns: { id: true, userId: true }, + where: and( + eq(pagadores.id, data.pagadorId), + eq(pagadores.userId, user.id), + ), + }); - if (!existing) { - return { success: false, error: "Pagador não encontrado." }; - } + if (!existing) { + return { success: false, error: "Pagador não encontrado." }; + } - let attempts = 0; - while (attempts < 5) { - const newCode = generateShareCode(); - try { - await db - .update(pagadores) - .set({ shareCode: newCode }) - .where(and(eq(pagadores.id, data.pagadorId), eq(pagadores.userId, user.id))); + let attempts = 0; + while (attempts < 5) { + const newCode = generateShareCode(); + try { + await db + .update(pagadores) + .set({ shareCode: newCode }) + .where( + and( + eq(pagadores.id, data.pagadorId), + eq(pagadores.userId, user.id), + ), + ); - revalidate(); - revalidatePath(`/pagadores/${data.pagadorId}`); - return { - success: true, - message: "Código atualizado com sucesso.", - code: newCode, - }; - } catch (error) { - if ( - error instanceof Error && - "constraint" in error && - // @ts-expect-error constraint is present in postgres errors - error.constraint === "pagadores_share_code_key" - ) { - attempts += 1; - continue; - } - throw error; - } - } + revalidate(); + revalidatePath(`/pagadores/${data.pagadorId}`); + return { + success: true, + message: "Código atualizado com sucesso.", + code: newCode, + }; + } catch (error) { + if ( + error instanceof Error && + "constraint" in error && + // @ts-expect-error constraint is present in postgres errors + error.constraint === "pagadores_share_code_key" + ) { + attempts += 1; + continue; + } + throw error; + } + } - return { - success: false, - error: "Não foi possível gerar um código único. Tente novamente.", - }; - } catch (error) { - return handleActionError(error); - } + return { + success: false, + error: "Não foi possível gerar um código único. Tente novamente.", + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/pagadores/layout.tsx b/app/(dashboard)/pagadores/layout.tsx index b7fb12d..6e12a23 100644 --- a/app/(dashboard)/pagadores/layout.tsx +++ b/app/(dashboard)/pagadores/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiGroupLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Pagadores | Opensheets", + title: "Pagadores | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Pagadores" - subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos." - /> - {children} -
- ); + return ( +
+ } + title="Pagadores" + subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/pagadores/loading.tsx b/app/(dashboard)/pagadores/loading.tsx index 85fa4b5..bbf3c35 100644 --- a/app/(dashboard)/pagadores/loading.tsx +++ b/app/(dashboard)/pagadores/loading.tsx @@ -5,53 +5,53 @@ import { Skeleton } from "@/components/ui/skeleton"; * Layout: Header + Input de compartilhamento + Grid de cards */ export default function PagadoresLoading() { - return ( -
-
- {/* Input de código de compartilhamento */} -
- -
- - -
-
+ return ( +
+
+ {/* Input de código de compartilhamento */} +
+ +
+ + +
+
- {/* Grid de cards de pagadores */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- {/* Avatar + Nome + Badge */} -
- -
- - -
- {i === 0 && ( - - )} -
+ {/* Grid de cards de pagadores */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Avatar + Nome + Badge */} +
+ +
+ + +
+ {i === 0 && ( + + )} +
- {/* Email */} - + {/* Email */} + - {/* Status */} -
- - -
+ {/* Status */} +
+ + +
- {/* Botões de ação */} -
- - - -
-
- ))} -
-
-
- ); + {/* Botões de ação */} +
+ + + +
+
+ ))} + + +
+ ); } diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx index 7ff25b9..1890cdd 100644 --- a/app/(dashboard)/pagadores/page.tsx +++ b/app/(dashboard)/pagadores/page.tsx @@ -1,86 +1,86 @@ -import { PagadoresPage } from "@/components/pagadores/pagadores-page"; -import type { PagadorStatus } from "@/lib/pagadores/constants"; -import { - PAGADOR_STATUS_OPTIONS, - DEFAULT_PAGADOR_AVATAR, - PAGADOR_ROLE_ADMIN, -} from "@/lib/pagadores/constants"; -import { getUserId } from "@/lib/auth/server"; -import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; import { readdir } from "node:fs/promises"; import path from "node:path"; +import { PagadoresPage } from "@/components/pagadores/pagadores-page"; +import { getUserId } from "@/lib/auth/server"; +import { fetchPagadoresWithAccess } from "@/lib/pagadores/access"; +import type { PagadorStatus } from "@/lib/pagadores/constants"; +import { + DEFAULT_PAGADOR_AVATAR, + PAGADOR_ROLE_ADMIN, + PAGADOR_STATUS_OPTIONS, +} from "@/lib/pagadores/constants"; const AVATAR_DIRECTORY = path.join(process.cwd(), "public", "avatares"); const AVATAR_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".svg", ".webp"]); async function loadAvatarOptions() { - try { - const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true }); + try { + const files = await readdir(AVATAR_DIRECTORY, { withFileTypes: true }); - const items = files - .filter((file) => file.isFile()) - .map((file) => file.name) - .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase())) - .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); + const items = files + .filter((file) => file.isFile()) + .map((file) => file.name) + .filter((file) => AVATAR_EXTENSIONS.has(path.extname(file).toLowerCase())) + .sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })); - if (items.length === 0) { - items.push(DEFAULT_PAGADOR_AVATAR); - } + if (items.length === 0) { + items.push(DEFAULT_PAGADOR_AVATAR); + } - return Array.from(new Set(items)); - } catch { - return [DEFAULT_PAGADOR_AVATAR]; - } + return Array.from(new Set(items)); + } catch { + return [DEFAULT_PAGADOR_AVATAR]; + } } const resolveStatus = (status: string | null): PagadorStatus => { - const normalized = status?.trim() ?? ""; - const found = PAGADOR_STATUS_OPTIONS.find( - (option) => option.toLowerCase() === normalized.toLowerCase() - ); - return found ?? PAGADOR_STATUS_OPTIONS[0]; + const normalized = status?.trim() ?? ""; + const found = PAGADOR_STATUS_OPTIONS.find( + (option) => option.toLowerCase() === normalized.toLowerCase(), + ); + return found ?? PAGADOR_STATUS_OPTIONS[0]; }; export default async function Page() { - const userId = await getUserId(); + const userId = await getUserId(); - const [pagadorRows, avatarOptions] = await Promise.all([ - fetchPagadoresWithAccess(userId), - loadAvatarOptions(), - ]); + const [pagadorRows, avatarOptions] = await Promise.all([ + fetchPagadoresWithAccess(userId), + loadAvatarOptions(), + ]); - const pagadoresData = pagadorRows - .map((pagador) => ({ - id: pagador.id, - name: pagador.name, - email: pagador.email, - avatarUrl: pagador.avatarUrl, - status: resolveStatus(pagador.status), - note: pagador.note, - role: pagador.role, - isAutoSend: pagador.isAutoSend ?? false, - createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(), - canEdit: pagador.canEdit, - sharedByName: pagador.sharedByName ?? null, - sharedByEmail: pagador.sharedByEmail ?? null, - shareId: pagador.shareId ?? null, - shareCode: pagador.canEdit ? pagador.shareCode ?? null : null, - })) - .sort((a, b) => { - // Admin sempre primeiro - if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) { - return -1; - } - if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) { - return 1; - } - // Se ambos são admin ou ambos não são, mantém ordem original - return 0; - }); + const pagadoresData = pagadorRows + .map((pagador) => ({ + id: pagador.id, + name: pagador.name, + email: pagador.email, + avatarUrl: pagador.avatarUrl, + status: resolveStatus(pagador.status), + note: pagador.note, + role: pagador.role, + isAutoSend: pagador.isAutoSend ?? false, + createdAt: pagador.createdAt?.toISOString() ?? new Date().toISOString(), + canEdit: pagador.canEdit, + sharedByName: pagador.sharedByName ?? null, + sharedByEmail: pagador.sharedByEmail ?? null, + shareId: pagador.shareId ?? null, + shareCode: pagador.canEdit ? (pagador.shareCode ?? null) : null, + })) + .sort((a, b) => { + // Admin sempre primeiro + if (a.role === PAGADOR_ROLE_ADMIN && b.role !== PAGADOR_ROLE_ADMIN) { + return -1; + } + if (a.role !== PAGADOR_ROLE_ADMIN && b.role === PAGADOR_ROLE_ADMIN) { + return 1; + } + // Se ambos são admin ou ambos não são, mantém ordem original + return 0; + }); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/pre-lancamentos/actions.ts b/app/(dashboard)/pre-lancamentos/actions.ts index 7f560d0..436597c 100644 --- a/app/(dashboard)/pre-lancamentos/actions.ts +++ b/app/(dashboard)/pre-lancamentos/actions.ts @@ -1,149 +1,149 @@ "use server"; +import { and, eq, inArray } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { inboxItems } from "@/db/schema"; import { handleActionError } from "@/lib/actions/helpers"; import type { ActionResult } from "@/lib/actions/types"; import { getUser } from "@/lib/auth/server"; import { db } from "@/lib/db"; -import { and, eq, inArray } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { z } from "zod"; const markProcessedSchema = z.object({ - inboxItemId: z.string().uuid("ID do item inválido"), + inboxItemId: z.string().uuid("ID do item inválido"), }); const discardInboxSchema = z.object({ - inboxItemId: z.string().uuid("ID do item inválido"), + inboxItemId: z.string().uuid("ID do item inválido"), }); const bulkDiscardSchema = z.object({ - inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"), + inboxItemIds: z.array(z.string().uuid()).min(1, "Selecione ao menos um item"), }); function revalidateInbox() { - revalidatePath("/pre-lancamentos"); - revalidatePath("/lancamentos"); - revalidatePath("/dashboard"); + revalidatePath("/pre-lancamentos"); + revalidatePath("/lancamentos"); + revalidatePath("/dashboard"); } /** * Mark an inbox item as processed after a lancamento was created */ export async function markInboxAsProcessedAction( - input: z.infer, + input: z.infer, ): Promise { - try { - const user = await getUser(); - const data = markProcessedSchema.parse(input); + try { + const user = await getUser(); + const data = markProcessedSchema.parse(input); - // Verificar se item existe e pertence ao usuário - const [item] = await db - .select() - .from(inboxItems) - .where( - and( - eq(inboxItems.id, data.inboxItemId), - eq(inboxItems.userId, user.id), - eq(inboxItems.status, "pending"), - ), - ) - .limit(1); + // Verificar se item existe e pertence ao usuário + const [item] = await db + .select() + .from(inboxItems) + .where( + and( + eq(inboxItems.id, data.inboxItemId), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending"), + ), + ) + .limit(1); - if (!item) { - return { success: false, error: "Item não encontrado ou já processado." }; - } + if (!item) { + return { success: false, error: "Item não encontrado ou já processado." }; + } - // Marcar item como processado - await db - .update(inboxItems) - .set({ - status: "processed", - processedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(inboxItems.id, data.inboxItemId)); + // Marcar item como processado + await db + .update(inboxItems) + .set({ + status: "processed", + processedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(inboxItems.id, data.inboxItemId)); - revalidateInbox(); + revalidateInbox(); - return { success: true, message: "Item processado com sucesso!" }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Item processado com sucesso!" }; + } catch (error) { + return handleActionError(error); + } } export async function discardInboxItemAction( - input: z.infer, + input: z.infer, ): Promise { - try { - const user = await getUser(); - const data = discardInboxSchema.parse(input); + try { + const user = await getUser(); + const data = discardInboxSchema.parse(input); - // Verificar se item existe e pertence ao usuário - const [item] = await db - .select() - .from(inboxItems) - .where( - and( - eq(inboxItems.id, data.inboxItemId), - eq(inboxItems.userId, user.id), - eq(inboxItems.status, "pending"), - ), - ) - .limit(1); + // Verificar se item existe e pertence ao usuário + const [item] = await db + .select() + .from(inboxItems) + .where( + and( + eq(inboxItems.id, data.inboxItemId), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending"), + ), + ) + .limit(1); - if (!item) { - return { success: false, error: "Item não encontrado ou já processado." }; - } + if (!item) { + return { success: false, error: "Item não encontrado ou já processado." }; + } - // Marcar item como descartado - await db - .update(inboxItems) - .set({ - status: "discarded", - discardedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(inboxItems.id, data.inboxItemId)); + // Marcar item como descartado + await db + .update(inboxItems) + .set({ + status: "discarded", + discardedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(inboxItems.id, data.inboxItemId)); - revalidateInbox(); + revalidateInbox(); - return { success: true, message: "Item descartado." }; - } catch (error) { - return handleActionError(error); - } + return { success: true, message: "Item descartado." }; + } catch (error) { + return handleActionError(error); + } } export async function bulkDiscardInboxItemsAction( - input: z.infer, + input: z.infer, ): Promise { - try { - const user = await getUser(); - const data = bulkDiscardSchema.parse(input); + try { + const user = await getUser(); + const data = bulkDiscardSchema.parse(input); - // Marcar todos os itens como descartados - await db - .update(inboxItems) - .set({ - status: "discarded", - discardedAt: new Date(), - updatedAt: new Date(), - }) - .where( - and( - inArray(inboxItems.id, data.inboxItemIds), - eq(inboxItems.userId, user.id), - eq(inboxItems.status, "pending"), - ), - ); + // Marcar todos os itens como descartados + await db + .update(inboxItems) + .set({ + status: "discarded", + discardedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + inArray(inboxItems.id, data.inboxItemIds), + eq(inboxItems.userId, user.id), + eq(inboxItems.status, "pending"), + ), + ); - revalidateInbox(); + revalidateInbox(); - return { - success: true, - message: `${data.inboxItemIds.length} item(s) descartado(s).`, - }; - } catch (error) { - return handleActionError(error); - } + return { + success: true, + message: `${data.inboxItemIds.length} item(s) descartado(s).`, + }; + } catch (error) { + return handleActionError(error); + } } diff --git a/app/(dashboard)/pre-lancamentos/data.ts b/app/(dashboard)/pre-lancamentos/data.ts index 4533982..1286385 100644 --- a/app/(dashboard)/pre-lancamentos/data.ts +++ b/app/(dashboard)/pre-lancamentos/data.ts @@ -2,153 +2,166 @@ * Data fetching functions for Pré-Lançamentos */ -import { db } from "@/lib/db"; -import { inboxItems, categorias, contas, cartoes, lancamentos } from "@/db/schema"; -import { eq, desc, and, gte } from "drizzle-orm"; -import type { InboxItem, SelectOption } from "@/components/pre-lancamentos/types"; +import { and, desc, eq, gte } from "drizzle-orm"; +import type { + InboxItem, + SelectOption, +} from "@/components/pre-lancamentos/types"; import { - fetchLancamentoFilterSources, - buildSluggedFilters, - buildOptionSets, + cartoes, + categorias, + contas, + inboxItems, + lancamentos, +} from "@/db/schema"; +import { db } from "@/lib/db"; +import { + buildOptionSets, + buildSluggedFilters, + fetchLancamentoFilterSources, } from "@/lib/lancamentos/page-helpers"; export async function fetchInboxItems( - userId: string, - status: "pending" | "processed" | "discarded" = "pending" + userId: string, + status: "pending" | "processed" | "discarded" = "pending", ): Promise { - const items = await db - .select() - .from(inboxItems) - .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) - .orderBy(desc(inboxItems.createdAt)); + const items = await db + .select() + .from(inboxItems) + .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, status))) + .orderBy(desc(inboxItems.createdAt)); - return items; + return items; } export async function fetchInboxItemById( - userId: string, - itemId: string + userId: string, + itemId: string, ): Promise { - const [item] = await db - .select() - .from(inboxItems) - .where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId))) - .limit(1); + const [item] = await db + .select() + .from(inboxItems) + .where(and(eq(inboxItems.id, itemId), eq(inboxItems.userId, userId))) + .limit(1); - return item ?? null; + return item ?? null; } export async function fetchCategoriasForSelect( - userId: string, - type?: string + userId: string, + type?: string, ): Promise { - const query = db - .select({ id: categorias.id, name: categorias.name }) - .from(categorias) - .where( - type - ? and(eq(categorias.userId, userId), eq(categorias.type, type)) - : eq(categorias.userId, userId) - ) - .orderBy(categorias.name); + const query = db + .select({ id: categorias.id, name: categorias.name }) + .from(categorias) + .where( + type + ? and(eq(categorias.userId, userId), eq(categorias.type, type)) + : eq(categorias.userId, userId), + ) + .orderBy(categorias.name); - return query; + return query; } -export async function fetchContasForSelect(userId: string): Promise { - const items = await db - .select({ id: contas.id, name: contas.name }) - .from(contas) - .where(and(eq(contas.userId, userId), eq(contas.status, "ativo"))) - .orderBy(contas.name); +export async function fetchContasForSelect( + userId: string, +): Promise { + const items = await db + .select({ id: contas.id, name: contas.name }) + .from(contas) + .where(and(eq(contas.userId, userId), eq(contas.status, "ativo"))) + .orderBy(contas.name); - return items; + return items; } export async function fetchCartoesForSelect( - userId: string + userId: string, ): Promise<(SelectOption & { lastDigits?: string })[]> { - const items = await db - .select({ id: cartoes.id, name: cartoes.name }) - .from(cartoes) - .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))) - .orderBy(cartoes.name); + const items = await db + .select({ id: cartoes.id, name: cartoes.name }) + .from(cartoes) + .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))) + .orderBy(cartoes.name); - return items; + return items; } export async function fetchPendingInboxCount(userId: string): Promise { - const items = await db - .select({ id: inboxItems.id }) - .from(inboxItems) - .where(and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending"))); + const items = await db + .select({ id: inboxItems.id }) + .from(inboxItems) + .where( + and(eq(inboxItems.userId, userId), eq(inboxItems.status, "pending")), + ); - return items.length; + return items.length; } /** * Fetch all data needed for the LancamentoDialog in inbox context */ export async function fetchInboxDialogData(userId: string): Promise<{ - pagadorOptions: SelectOption[]; - splitPagadorOptions: SelectOption[]; - defaultPagadorId: string | null; - contaOptions: SelectOption[]; - cartaoOptions: SelectOption[]; - categoriaOptions: SelectOption[]; - estabelecimentos: string[]; + pagadorOptions: SelectOption[]; + splitPagadorOptions: SelectOption[]; + defaultPagadorId: string | null; + contaOptions: SelectOption[]; + cartaoOptions: SelectOption[]; + categoriaOptions: SelectOption[]; + estabelecimentos: string[]; }> { - const filterSources = await fetchLancamentoFilterSources(userId); - const sluggedFilters = buildSluggedFilters(filterSources); + const filterSources = await fetchLancamentoFilterSources(userId); + const sluggedFilters = buildSluggedFilters(filterSources); - const { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - } = buildOptionSets({ - ...sluggedFilters, - pagadorRows: filterSources.pagadorRows, - }); + const { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + } = buildOptionSets({ + ...sluggedFilters, + pagadorRows: filterSources.pagadorRows, + }); - // Fetch recent establishments (same approach as getRecentEstablishmentsAction) - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + // Fetch recent establishments (same approach as getRecentEstablishmentsAction) + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - const recentEstablishments = await db - .select({ name: lancamentos.name }) - .from(lancamentos) - .where( - and( - eq(lancamentos.userId, userId), - gte(lancamentos.purchaseDate, threeMonthsAgo) - ) - ) - .orderBy(desc(lancamentos.purchaseDate)); + const recentEstablishments = await db + .select({ name: lancamentos.name }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + gte(lancamentos.purchaseDate, threeMonthsAgo), + ), + ) + .orderBy(desc(lancamentos.purchaseDate)); - // Remove duplicates and filter empty names - const filteredNames: string[] = recentEstablishments - .map((r: { name: string }) => r.name) - .filter( - (name: string | null): name is string => - name != null && - name.trim().length > 0 && - !name.toLowerCase().startsWith("pagamento fatura") - ); - const estabelecimentos = Array.from(new Set(filteredNames)).slice( - 0, - 100 - ); + // Remove duplicates and filter empty names + const filteredNames: string[] = recentEstablishments + .map((r: { name: string }) => r.name) + .filter( + (name: string | null): name is string => + name != null && + name.trim().length > 0 && + !name.toLowerCase().startsWith("pagamento fatura"), + ); + const estabelecimentos = Array.from(new Set(filteredNames)).slice( + 0, + 100, + ); - return { - pagadorOptions, - splitPagadorOptions, - defaultPagadorId, - contaOptions, - cartaoOptions, - categoriaOptions, - estabelecimentos, - }; + return { + pagadorOptions, + splitPagadorOptions, + defaultPagadorId, + contaOptions, + cartaoOptions, + categoriaOptions, + estabelecimentos, + }; } diff --git a/app/(dashboard)/pre-lancamentos/layout.tsx b/app/(dashboard)/pre-lancamentos/layout.tsx index 1579c32..d771a79 100644 --- a/app/(dashboard)/pre-lancamentos/layout.tsx +++ b/app/(dashboard)/pre-lancamentos/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiInboxLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Pré-Lançamentos | Opensheets", + title: "Pré-Lançamentos | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Pré-Lançamentos" - subtitle="Notificações capturadas aguardando processamento" - /> - {children} -
- ); + return ( +
+ } + title="Pré-Lançamentos" + subtitle="Notificações capturadas aguardando processamento" + /> + {children} +
+ ); } diff --git a/app/(dashboard)/pre-lancamentos/loading.tsx b/app/(dashboard)/pre-lancamentos/loading.tsx index badd381..f3fdd73 100644 --- a/app/(dashboard)/pre-lancamentos/loading.tsx +++ b/app/(dashboard)/pre-lancamentos/loading.tsx @@ -2,32 +2,32 @@ import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { - return ( -
-
-
- - -
-
- {Array.from({ length: 6 }).map((_, i) => ( - -
-
- - -
- - -
- - -
-
-
- ))} -
-
-
- ); + return ( +
+
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + +
+
+ + +
+ + +
+ + +
+
+
+ ))} +
+
+
+ ); } diff --git a/app/(dashboard)/pre-lancamentos/page.tsx b/app/(dashboard)/pre-lancamentos/page.tsx index b1d3279..6a11212 100644 --- a/app/(dashboard)/pre-lancamentos/page.tsx +++ b/app/(dashboard)/pre-lancamentos/page.tsx @@ -3,25 +3,25 @@ import { getUserId } from "@/lib/auth/server"; import { fetchInboxDialogData, fetchInboxItems } from "./data"; export default async function Page() { - const userId = await getUserId(); + const userId = await getUserId(); - const [items, dialogData] = await Promise.all([ - fetchInboxItems(userId, "pending"), - fetchInboxDialogData(userId), - ]); + const [items, dialogData] = await Promise.all([ + fetchInboxItems(userId, "pending"), + fetchInboxDialogData(userId), + ]); - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/relatorios/cartoes/layout.tsx b/app/(dashboard)/relatorios/cartoes/layout.tsx index ec5dab2..fdea913 100644 --- a/app/(dashboard)/relatorios/cartoes/layout.tsx +++ b/app/(dashboard)/relatorios/cartoes/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiBankCard2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Relatório de Cartões | Opensheets", + title: "Relatório de Cartões | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Relatório de Cartões" - subtitle="Análise detalhada do uso dos seus cartões de crédito." - /> - {children} -
- ); + return ( +
+ } + title="Relatório de Cartões" + subtitle="Análise detalhada do uso dos seus cartões de crédito." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/relatorios/cartoes/loading.tsx b/app/(dashboard)/relatorios/cartoes/loading.tsx index 557d6e0..c337eb5 100644 --- a/app/(dashboard)/relatorios/cartoes/loading.tsx +++ b/app/(dashboard)/relatorios/cartoes/loading.tsx @@ -2,84 +2,84 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { - return ( -
-
- - -
+ return ( +
+
+ + +
- + -
-
- - - - - -
- - - -
-
- {[1, 2, 3].map((i) => ( - - ))} -
-
-
-
+
+
+ + + + + +
+ + + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+
-
- +
+ - - - - - - - - + + + + + + + + -
- - - - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - +
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + - - - - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - -
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
- - - - - - {[1, 2, 3, 4, 5, 6].map((i) => ( - - ))} - - -
-
-
- ); + + + + + + {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} + + + + +
+ ); } diff --git a/app/(dashboard)/relatorios/cartoes/page.tsx b/app/(dashboard)/relatorios/cartoes/page.tsx index 27b4a16..2027637 100644 --- a/app/(dashboard)/relatorios/cartoes/page.tsx +++ b/app/(dashboard)/relatorios/cartoes/page.tsx @@ -1,3 +1,4 @@ +import { RiBankCard2Line } from "@remixicon/react"; import MonthNavigation from "@/components/month-picker/month-navigation"; import { CardCategoryBreakdown } from "@/components/relatorios/cartoes/card-category-breakdown"; import { CardInvoiceStatus } from "@/components/relatorios/cartoes/card-invoice-status"; @@ -7,79 +8,78 @@ import { CardsOverview } from "@/components/relatorios/cartoes/cards-overview"; import { getUser } from "@/lib/auth/server"; import { fetchCartoesReportData } from "@/lib/relatorios/cartoes-report"; import { parsePeriodParam } from "@/lib/utils/period"; -import { RiBankCard2Line } from "@remixicon/react"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string, + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? (value[0] ?? null) : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; export default async function RelatorioCartoesPage({ - searchParams, + searchParams, }: PageProps) { - const user = await getUser(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const cartaoParam = getSingleParam(resolvedSearchParams, "cartao"); - const { period: selectedPeriod } = parsePeriodParam(periodoParam); + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const cartaoParam = getSingleParam(resolvedSearchParams, "cartao"); + const { period: selectedPeriod } = parsePeriodParam(periodoParam); - const data = await fetchCartoesReportData( - user.id, - selectedPeriod, - cartaoParam, - ); + const data = await fetchCartoesReportData( + user.id, + selectedPeriod, + cartaoParam, + ); - return ( -
- + return ( +
+ -
-
- -
+
+
+ +
-
- {data.selectedCard ? ( - <> - +
+ {data.selectedCard ? ( + <> + -
- - -
+
+ + +
- - - ) : ( -
- -

Nenhum cartão selecionado

-

- Selecione um cartão na lista ao lado para ver detalhes. -

-
- )} -
-
-
- ); + + + ) : ( +
+ +

Nenhum cartão selecionado

+

+ Selecione um cartão na lista ao lado para ver detalhes. +

+
+ )} + + +
+ ); } diff --git a/app/(dashboard)/relatorios/categorias/data.ts b/app/(dashboard)/relatorios/categorias/data.ts new file mode 100644 index 0000000..bae4ce2 --- /dev/null +++ b/app/(dashboard)/relatorios/categorias/data.ts @@ -0,0 +1,12 @@ +import { asc, eq } from "drizzle-orm"; +import { type Categoria, categorias } from "@/db/schema"; +import { db } from "@/lib/db"; + +export async function fetchUserCategories( + userId: string, +): Promise { + return db.query.categorias.findMany({ + where: eq(categorias.userId, userId), + orderBy: [asc(categorias.name)], + }); +} diff --git a/app/(dashboard)/relatorios/categorias/layout.tsx b/app/(dashboard)/relatorios/categorias/layout.tsx index 959f5e3..e076497 100644 --- a/app/(dashboard)/relatorios/categorias/layout.tsx +++ b/app/(dashboard)/relatorios/categorias/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiFileChartLine } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Relatórios | Opensheets", + title: "Relatórios | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Relatórios de Categorias" - subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo." - /> - {children} -
- ); + return ( +
+ } + title="Relatórios de Categorias" + subtitle="Acompanhe a evolução dos seus gastos e receitas por categoria ao longo do tempo." + /> + {children} +
+ ); } diff --git a/app/(dashboard)/relatorios/categorias/loading.tsx b/app/(dashboard)/relatorios/categorias/loading.tsx index e832b3f..c39eac1 100644 --- a/app/(dashboard)/relatorios/categorias/loading.tsx +++ b/app/(dashboard)/relatorios/categorias/loading.tsx @@ -1,9 +1,9 @@ import { CategoryReportSkeleton } from "@/components/skeletons/category-report-skeleton"; export default function Loading() { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/relatorios/categorias/page.tsx b/app/(dashboard)/relatorios/categorias/page.tsx index 4cbe29d..8ffb4b7 100644 --- a/app/(dashboard)/relatorios/categorias/page.tsx +++ b/app/(dashboard)/relatorios/categorias/page.tsx @@ -1,118 +1,114 @@ -import { CategoryReportPage } from "@/components/relatorios/category-report-page"; -import { getUserId } from "@/lib/auth/server"; -import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period"; -import { validateDateRange } from "@/lib/relatorios/utils"; -import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report"; -import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; -import type { CategoryReportFilters } from "@/lib/relatorios/types"; -import type { - CategoryOption, - FilterState, -} from "@/components/relatorios/types"; -import { db } from "@/lib/db"; -import { categorias, type Categoria } from "@/db/schema"; -import { eq, asc } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { CategoryReportPage } from "@/components/relatorios/category-report-page"; +import type { + CategoryOption, + FilterState, +} from "@/components/relatorios/types"; +import type { Categoria } from "@/db/schema"; +import { getUserId } from "@/lib/auth/server"; +import { fetchCategoryChartData } from "@/lib/relatorios/fetch-category-chart-data"; +import { fetchCategoryReport } from "@/lib/relatorios/fetch-category-report"; +import type { CategoryReportFilters } from "@/lib/relatorios/types"; +import { validateDateRange } from "@/lib/relatorios/utils"; +import { addMonthsToPeriod, getCurrentPeriod } from "@/lib/utils/period"; +import { fetchUserCategories } from "./data"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string + params: Record | undefined, + key: string, ): string | null => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? value[0] ?? null : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; export default async function Page({ searchParams }: PageProps) { - // Get authenticated user - const userId = await getUserId(); + // Get authenticated user + const userId = await getUserId(); - // Resolve search params - const resolvedSearchParams = searchParams ? await searchParams : undefined; + // Resolve search params + const resolvedSearchParams = searchParams ? await searchParams : undefined; - // Extract query params - const inicioParam = getSingleParam(resolvedSearchParams, "inicio"); - const fimParam = getSingleParam(resolvedSearchParams, "fim"); - const categoriasParam = getSingleParam(resolvedSearchParams, "categorias"); + // Extract query params + const inicioParam = getSingleParam(resolvedSearchParams, "inicio"); + const fimParam = getSingleParam(resolvedSearchParams, "fim"); + const categoriasParam = getSingleParam(resolvedSearchParams, "categorias"); - // Calculate default period (last 6 months) - const currentPeriod = getCurrentPeriod(); - const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current + // Calculate default period (last 6 months) + const currentPeriod = getCurrentPeriod(); + const defaultStartPeriod = addMonthsToPeriod(currentPeriod, -5); // 6 months including current - // Use params or defaults - const startPeriod = inicioParam ?? defaultStartPeriod; - const endPeriod = fimParam ?? currentPeriod; + // Use params or defaults + const startPeriod = inicioParam ?? defaultStartPeriod; + const endPeriod = fimParam ?? currentPeriod; - // Parse selected categories - const selectedCategoryIds = categoriasParam - ? categoriasParam.split(",").filter(Boolean) - : []; + // Parse selected categories + const selectedCategoryIds = categoriasParam + ? categoriasParam.split(",").filter(Boolean) + : []; - // Validate date range - const validation = validateDateRange(startPeriod, endPeriod); - if (!validation.isValid) { - // Redirect to default if validation fails - redirect( - `/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}` - ); - } + // Validate date range + const validation = validateDateRange(startPeriod, endPeriod); + if (!validation.isValid) { + // Redirect to default if validation fails + redirect( + `/relatorios/categorias?inicio=${defaultStartPeriod}&fim=${currentPeriod}`, + ); + } - // Fetch all categories for the user - const categoriaRows = await db.query.categorias.findMany({ - where: eq(categorias.userId, userId), - orderBy: [asc(categorias.name)], - }); + // Fetch all categories for the user + const categoriaRows = await fetchUserCategories(userId); - // Map to CategoryOption format - const categoryOptions: CategoryOption[] = categoriaRows.map( - (cat: Categoria): CategoryOption => ({ - id: cat.id, - name: cat.name, - icon: cat.icon, - type: cat.type as "despesa" | "receita", - }) - ); + // Map to CategoryOption format + const categoryOptions: CategoryOption[] = categoriaRows.map( + (cat: Categoria): CategoryOption => ({ + id: cat.id, + name: cat.name, + icon: cat.icon, + type: cat.type as "despesa" | "receita", + }), + ); - // Build filters for data fetching - const filters: CategoryReportFilters = { - startPeriod, - endPeriod, - categoryIds: - selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, - }; + // Build filters for data fetching + const filters: CategoryReportFilters = { + startPeriod, + endPeriod, + categoryIds: + selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, + }; - // Fetch report data - const reportData = await fetchCategoryReport(userId, filters); + // Fetch report data + const reportData = await fetchCategoryReport(userId, filters); - // Fetch chart data with same filters - const chartData = await fetchCategoryChartData( - userId, - startPeriod, - endPeriod, - selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined - ); + // Fetch chart data with same filters + const chartData = await fetchCategoryChartData( + userId, + startPeriod, + endPeriod, + selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, + ); - // Build initial filter state for client component - const initialFilters: FilterState = { - selectedCategories: selectedCategoryIds, - startPeriod, - endPeriod, - }; + // Build initial filter state for client component + const initialFilters: FilterState = { + selectedCategories: selectedCategoryIds, + startPeriod, + endPeriod, + }; - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/(dashboard)/top-estabelecimentos/layout.tsx b/app/(dashboard)/top-estabelecimentos/layout.tsx index c6d619e..04e3bc1 100644 --- a/app/(dashboard)/top-estabelecimentos/layout.tsx +++ b/app/(dashboard)/top-estabelecimentos/layout.tsx @@ -1,23 +1,23 @@ -import PageDescription from "@/components/page-description"; import { RiStore2Line } from "@remixicon/react"; +import PageDescription from "@/components/page-description"; export const metadata = { - title: "Top Estabelecimentos | Opensheets", + title: "Top Estabelecimentos | Opensheets", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- } - title="Top Estabelecimentos" - subtitle="Análise dos locais onde você mais compra e gasta" - /> - {children} -
- ); + return ( +
+ } + title="Top Estabelecimentos" + subtitle="Análise dos locais onde você mais compra e gasta" + /> + {children} +
+ ); } diff --git a/app/(dashboard)/top-estabelecimentos/loading.tsx b/app/(dashboard)/top-estabelecimentos/loading.tsx index 498e8f9..b0fb8e5 100644 --- a/app/(dashboard)/top-estabelecimentos/loading.tsx +++ b/app/(dashboard)/top-estabelecimentos/loading.tsx @@ -2,57 +2,57 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; export default function Loading() { - return ( -
-
-
- - -
- -
+ return ( +
+
+
+ + +
+ +
-
- {[1, 2, 3, 4].map((i) => ( - - - - - - ))} -
+
+ {[1, 2, 3, 4].map((i) => ( + + + + + + ))} +
-
- - -
+
+ + +
-
-
- - - - - - {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( - - ))} - - -
-
- - - - - - {[1, 2, 3, 4, 5].map((i) => ( - - ))} - - -
-
-
- ); +
+
+ + + + + + {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( + + ))} + + +
+
+ + + + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + +
+
+
+ ); } diff --git a/app/(dashboard)/top-estabelecimentos/page.tsx b/app/(dashboard)/top-estabelecimentos/page.tsx index ecb8628..65ff9d5 100644 --- a/app/(dashboard)/top-estabelecimentos/page.tsx +++ b/app/(dashboard)/top-estabelecimentos/page.tsx @@ -6,71 +6,71 @@ import { TopCategories } from "@/components/top-estabelecimentos/top-categories" import { Card } from "@/components/ui/card"; import { getUser } from "@/lib/auth/server"; import { - fetchTopEstabelecimentosData, - type PeriodFilter, + fetchTopEstabelecimentosData, + type PeriodFilter, } from "@/lib/top-estabelecimentos/fetch-data"; import { parsePeriodParam } from "@/lib/utils/period"; type PageSearchParams = Promise>; type PageProps = { - searchParams?: PageSearchParams; + searchParams?: PageSearchParams; }; const getSingleParam = ( - params: Record | undefined, - key: string, + params: Record | undefined, + key: string, ) => { - const value = params?.[key]; - if (!value) return null; - return Array.isArray(value) ? (value[0] ?? null) : value; + const value = params?.[key]; + if (!value) return null; + return Array.isArray(value) ? (value[0] ?? null) : value; }; const validatePeriodFilter = (value: string | null): PeriodFilter => { - if (value === "3" || value === "6" || value === "12") { - return value; - } - return "6"; + if (value === "3" || value === "6" || value === "12") { + return value; + } + return "6"; }; export default async function TopEstabelecimentosPage({ - searchParams, + searchParams, }: PageProps) { - const user = await getUser(); - const resolvedSearchParams = searchParams ? await searchParams : undefined; - const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); - const mesesParam = getSingleParam(resolvedSearchParams, "meses"); + const user = await getUser(); + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const periodoParam = getSingleParam(resolvedSearchParams, "periodo"); + const mesesParam = getSingleParam(resolvedSearchParams, "meses"); - const { period: currentPeriod } = parsePeriodParam(periodoParam); - const periodFilter = validatePeriodFilter(mesesParam); + const { period: currentPeriod } = parsePeriodParam(periodoParam); + const periodFilter = validatePeriodFilter(mesesParam); - const data = await fetchTopEstabelecimentosData( - user.id, - currentPeriod, - periodFilter, - ); + const data = await fetchTopEstabelecimentosData( + user.id, + currentPeriod, + periodFilter, + ); - return ( -
- - - Selecione o período - - - + return ( +
+ + + Selecione o período + + + - + - + -
-
- -
-
- -
-
-
- ); +
+
+ +
+
+ +
+
+
+ ); } diff --git a/app/(landing-page)/page.tsx b/app/(landing-page)/page.tsx index 9e1e7d3..96472c9 100644 --- a/app/(landing-page)/page.tsx +++ b/app/(landing-page)/page.tsx @@ -1,866 +1,866 @@ +import { + RiArrowRightSLine, + RiBankCard2Line, + RiBarChartBoxLine, + RiCalendarLine, + RiCodeSSlashLine, + RiDatabase2Line, + RiDeviceLine, + RiDownloadCloudLine, + RiEyeOffLine, + RiFileTextLine, + RiFlashlightLine, + RiGithubFill, + RiLineChartLine, + RiLockLine, + RiPercentLine, + RiPieChartLine, + RiRobot2Line, + RiShieldCheckLine, + RiTeamLine, + RiTimeLine, + RiWalletLine, +} from "@remixicon/react"; +import Image from "next/image"; +import Link from "next/link"; import { AnimatedThemeToggler } from "@/components/animated-theme-toggler"; import { Logo } from "@/components/logo"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { getOptionalUserSession } from "@/lib/auth/server"; -import { - RiArrowRightSLine, - RiBankCard2Line, - RiBarChartBoxLine, - RiCalendarLine, - RiCodeSSlashLine, - RiDatabase2Line, - RiDeviceLine, - RiGithubFill, - RiLineChartLine, - RiLockLine, - RiPieChartLine, - RiShieldCheckLine, - RiTimeLine, - RiWalletLine, - RiRobot2Line, - RiTeamLine, - RiFileTextLine, - RiDownloadCloudLine, - RiEyeOffLine, - RiFlashlightLine, - RiPercentLine, -} from "@remixicon/react"; -import Image from "next/image"; -import Link from "next/link"; export default async function Page() { - const session = await getOptionalUserSession(); + const session = await getOptionalUserSession(); - return ( -
- {/* Navigation */} -
-
-
- -
+ return ( +
+ {/* Navigation */} +
+
+
+ +
- {/* Center Navigation Links */} - + {/* Center Navigation Links */} + - -
-
+ +
+
- {/* Hero Section */} -
-
-
- - - Projeto Open Source - + {/* Hero Section */} +
+
+
+ + + Projeto Open Source + -

- Suas finanças, - do seu jeito -

+

+ Suas finanças, + do seu jeito +

-

- Um projeto pessoal de gestão financeira. Self-hosted, sem Open - Finance, sem sincronização automática. Rode no seu computador ou - servidor e tenha controle total sobre suas finanças. -

+

+ Um projeto pessoal de gestão financeira. Self-hosted, sem Open + Finance, sem sincronização automática. Rode no seu computador ou + servidor e tenha controle total sobre suas finanças. +

-
-

- - ⚠️ Aviso importante: - {" "} - Este sistema requer disciplina. Você precisa registrar - manualmente cada transação. Se prefere algo automático, este - projeto não é pra você. -

-
+
+

+ + ⚠️ Aviso importante: + {" "} + Este sistema requer disciplina. Você precisa registrar + manualmente cada transação. Se prefere algo automático, este + projeto não é pra você. +

+
-
- - - - - - -
+
+ + + + + + +
-
-
- - Seus dados, seu servidor -
-
- - 100% Open Source -
-
-
-
-
+
+
+ + Seus dados, seu servidor +
+
+ + 100% Open Source +
+
+
+
+
- {/* Dashboard Preview Section */} -
-
-
- opensheets Dashboard Preview - opensheets Dashboard Preview -
-
-
+ {/* Dashboard Preview Section */} +
+
+
+ opensheets Dashboard Preview + opensheets Dashboard Preview +
+
+
- {/* What's Here Section */} -
-
-
-
- - O que tem aqui - -

- Funcionalidades que importam -

-

- Ferramentas simples para organizar suas contas, cartões, gastos - e receitas -

-
+ {/* What's Here Section */} +
+
+
+
+ + O que tem aqui + +

+ Funcionalidades que importam +

+

+ Ferramentas simples para organizar suas contas, cartões, gastos + e receitas +

+
-
- - -
-
- -
-
-

- Contas e transações -

-

- Registre suas contas bancárias, cartões e dinheiro. - Adicione receitas, despesas e transferências. Organize - por categorias. Extratos detalhados por conta. -

-
-
-
-
+
+ + +
+
+ +
+
+

+ Contas e transações +

+

+ Registre suas contas bancárias, cartões e dinheiro. + Adicione receitas, despesas e transferências. Organize + por categorias. Extratos detalhados por conta. +

+
+
+
+
- - -
-
- -
-
-

- Parcelamentos avançados -

-

- Controle completo de compras parceladas. Antecipe - parcelas com cálculo automático de desconto. Veja - análise consolidada de todas as parcelas em aberto. -

-
-
-
-
+ + +
+
+ +
+
+

+ Parcelamentos avançados +

+

+ Controle completo de compras parceladas. Antecipe + parcelas com cálculo automático de desconto. Veja + análise consolidada de todas as parcelas em aberto. +

+
+
+
+
- - -
-
- -
-
-

- Insights com IA -

-

- Análises financeiras geradas por IA (Claude, GPT, - Gemini). Insights personalizados sobre seus padrões de - gastos e recomendações inteligentes. -

-
-
-
-
+ + +
+
+ +
+
+

+ Insights com IA +

+

+ Análises financeiras geradas por IA (Claude, GPT, + Gemini). Insights personalizados sobre seus padrões de + gastos e recomendações inteligentes. +

+
+
+
+
- - -
-
- -
-
-

- Relatórios e gráficos -

-

- Dashboard com 20+ widgets interativos. Relatórios - detalhados por categoria. Gráficos de evolução e - comparativos. Exportação em PDF e Excel. -

-
-
-
-
+ + +
+
+ +
+
+

+ Relatórios e gráficos +

+

+ Dashboard com 20+ widgets interativos. Relatórios + detalhados por categoria. Gráficos de evolução e + comparativos. Exportação em PDF e Excel. +

+
+
+
+
- - -
-
- -
-
-

- Faturas de cartão -

-

- Cadastre seus cartões e acompanhe as faturas por - período. Veja o que ainda não foi fechado. Controle - limites, vencimentos e fechamentos. -

-
-
-
-
+ + +
+
+ +
+
+

+ Faturas de cartão +

+

+ Cadastre seus cartões e acompanhe as faturas por + período. Veja o que ainda não foi fechado. Controle + limites, vencimentos e fechamentos. +

+
+
+
+
- - -
-
- -
-
-

- Gestão colaborativa -

-

- Compartilhe pagadores com permissões granulares (admin/ - viewer). Notificações automáticas por e-mail. Colabore - em lançamentos compartilhados. -

-
-
-
-
+ + +
+
+ +
+
+

+ Gestão colaborativa +

+

+ Compartilhe pagadores com permissões granulares (admin/ + viewer). Notificações automáticas por e-mail. Colabore + em lançamentos compartilhados. +

+
+
+
+
- - -
-
- -
-
-

- Categorias e orçamentos -

-

- Crie categorias personalizadas. Defina orçamentos - mensais e acompanhe o quanto gastou vs. planejado com - indicadores visuais. -

-
-
-
-
+ + +
+
+ +
+
+

+ Categorias e orçamentos +

+

+ Crie categorias personalizadas. Defina orçamentos + mensais e acompanhe o quanto gastou vs. planejado com + indicadores visuais. +

+
+
+
+
- - -
-
- -
-
-

- Anotações e tarefas -

-

- Crie notas de texto e listas de tarefas com checkboxes. - Sistema de arquivamento para manter histórico. Organize - seus planejamentos financeiros. -

-
-
-
-
+ + +
+
+ +
+
+

+ Anotações e tarefas +

+

+ Crie notas de texto e listas de tarefas com checkboxes. + Sistema de arquivamento para manter histórico. Organize + seus planejamentos financeiros. +

+
+
+
+
- - -
-
- -
-
-

- Calendário financeiro -

-

- Visualize todas as transações em calendário mensal. - Navegação intuitiva por data. Nunca perca prazos de - pagamentos importantes. -

-
-
-
-
+ + +
+
+ +
+
+

+ Calendário financeiro +

+

+ Visualize todas as transações em calendário mensal. + Navegação intuitiva por data. Nunca perca prazos de + pagamentos importantes. +

+
+
+
+
- - -
-
- -
-
-

- Importação em massa -

-

- Cole múltiplos lançamentos de uma vez. Economize tempo - ao registrar várias transações. Formatação inteligente - para facilitar a entrada de dados. -

-
-
-
-
+ + +
+
+ +
+
+

+ Importação em massa +

+

+ Cole múltiplos lançamentos de uma vez. Economize tempo + ao registrar várias transações. Formatação inteligente + para facilitar a entrada de dados. +

+
+
+
+
- - -
-
- -
-
-

- Modo privacidade -

-

- Oculte valores sensíveis com um clique. Tema dark/light - adaptável. Preferências personalizáveis. Calculadora - integrada para planejamento. -

-
-
-
-
+ + +
+
+ +
+
+

+ Modo privacidade +

+

+ Oculte valores sensíveis com um clique. Tema dark/light + adaptável. Preferências personalizáveis. Calculadora + integrada para planejamento. +

+
+
+
+
- - -
-
- -
-
-

- Performance otimizada -

-

- Dashboard carrega em ~200-500ms com 18+ queries - paralelas. Índices otimizados. Type-safe em toda - codebase. Isolamento completo de dados por usuário. -

-
-
-
-
-
-
-
-
+ + +
+
+ +
+
+

+ Performance otimizada +

+

+ Dashboard carrega em ~200-500ms com 18+ queries + paralelas. Índices otimizados. Type-safe em toda + codebase. Isolamento completo de dados por usuário. +

+
+
+
+
+
+
+
+ - {/* Tech Stack Section */} -
-
-
-
- - Stack técnica - -

- Construído com tecnologias modernas -

-

- Open source, self-hosted e fácil de customizar -

-
+ {/* Tech Stack Section */} +
+
+
+
+ + Stack técnica + +

+ Construído com tecnologias modernas +

+

+ Open source, self-hosted e fácil de customizar +

+
-
- - -
- -
-

Frontend

-

- Next.js 16, TypeScript, Tailwind CSS, shadcn/ui -

-

- Interface moderna e responsiva com React 19 e App Router -

-
-
-
-
+
+ + +
+ +
+

Frontend

+

+ Next.js 16, TypeScript, Tailwind CSS, shadcn/ui +

+

+ Interface moderna e responsiva com React 19 e App Router +

+
+
+
+
- - -
- -
-

Backend

-

- PostgreSQL 18, Drizzle ORM, Better Auth -

-

- Banco relacional robusto com type-safe ORM -

-
-
-
-
+ + +
+ +
+

Backend

+

+ PostgreSQL 18, Drizzle ORM, Better Auth +

+

+ Banco relacional robusto com type-safe ORM +

+
+
+
+
- - -
- -
-

Segurança

-

- Better Auth com OAuth (Google) e autenticação por email -

-

- Sessões seguras e proteção de rotas por middleware -

-
-
-
-
+ + +
+ +
+

Segurança

+

+ Better Auth com OAuth (Google) e autenticação por email +

+

+ Sessões seguras e proteção de rotas por middleware +

+
+
+
+
- - -
- -
-

Deploy

-

- Docker com multi-stage build, health checks e volumes - persistentes -

-

- Fácil de rodar localmente ou em qualquer servidor -

-
-
-
-
-
+ + +
+ +
+

Deploy

+

+ Docker com multi-stage build, health checks e volumes + persistentes +

+

+ Fácil de rodar localmente ou em qualquer servidor +

+
+
+
+
+
-
-

- Seus dados ficam no seu controle. Pode rodar localmente ou no - seu próprio servidor. -

-
-
-
-
+
+

+ Seus dados ficam no seu controle. Pode rodar localmente ou no + seu próprio servidor. +

+
+
+
+
- {/* How to run Section */} -
-
-
-
- - Como usar - -

- Rode no seu computador -

-

- Não há versão hospedada online. Você precisa rodar localmente. -

-
+ {/* How to run Section */} +
+
+
+
+ + Como usar + +

+ Rode no seu computador +

+

+ Não há versão hospedada online. Você precisa rodar localmente. +

+
-
- - -
-
- 1 -
-
-

- Clone o repositório -

- - git clone - https://github.com/felipegcoutinho/opensheets-app.git - -
-
-
-
+
+ + +
+
+ 1 +
+
+

+ Clone o repositório +

+ + git clone + https://github.com/felipegcoutinho/opensheets-app.git + +
+
+
+
- - -
-
- 2 -
-
-

- Configure as variáveis de ambiente -

-

- Copie o{" "} - - .env.example - {" "} - para .env{" "} - e configure o banco de dados -

-
-
-
-
+ + +
+
+ 2 +
+
+

+ Configure as variáveis de ambiente +

+

+ Copie o{" "} + + .env.example + {" "} + para .env{" "} + e configure o banco de dados +

+
+
+
+
- - -
-
- 3 -
-
-

- Suba o banco via Docker -

- - docker compose up db -d - -
-
-
-
+ + +
+
+ 3 +
+
+

+ Suba o banco via Docker +

+ + docker compose up db -d + +
+
+
+
- - -
-
- 4 -
-
-

- Rode a aplicação localmente -

-
- - pnpm install - - - pnpm db:push - - - pnpm dev - -
-
-
-
-
-
+ + +
+
+ 4 +
+
+

+ Rode a aplicação localmente +

+
+ + pnpm install + + + pnpm db:push + + + pnpm dev + +
+
+
+
+
+
-
- - Ver documentação completa → - -
-
-
-
+
+ + Ver documentação completa → + +
+
+
+
- {/* Who is this for Section */} -
-
-
-
-

- Para quem funciona? -

-

- O opensheets funciona melhor se você: -

-
+ {/* Who is this for Section */} +
+
+
+
+

+ Para quem funciona? +

+

+ O opensheets funciona melhor se você: +

+
-
- - -
-
- -
-
-

- Tem disciplina de registrar gastos -

-

- Não se importa em dedicar alguns minutos por dia ou - semana para manter tudo atualizado -

-
-
-
-
+
+ + +
+
+ +
+
+

+ Tem disciplina de registrar gastos +

+

+ Não se importa em dedicar alguns minutos por dia ou + semana para manter tudo atualizado +

+
+
+
+
- - -
-
- -
-
-

- Quer controle total sobre seus dados -

-

- Prefere hospedar seus próprios dados ao invés de - depender de serviços terceiros -

-
-
-
-
+ + +
+
+ +
+
+

+ Quer controle total sobre seus dados +

+

+ Prefere hospedar seus próprios dados ao invés de + depender de serviços terceiros +

+
+
+
+
- - -
-
- -
-
-

- Gosta de entender exatamente onde o dinheiro vai -

-

- Quer visualizar padrões de gastos e tomar decisões - informadas -

-
-
-
-
-
+ + +
+
+ +
+
+

+ Gosta de entender exatamente onde o dinheiro vai +

+

+ Quer visualizar padrões de gastos e tomar decisões + informadas +

+
+
+
+
+
-
-

- Se você não se encaixa nisso, provavelmente vai abandonar depois - de uma semana. E tudo bem! Existem outras ferramentas com - sincronização automática que podem funcionar melhor pra você. -

-
-
-
-
+
+

+ Se você não se encaixa nisso, provavelmente vai abandonar depois + de uma semana. E tudo bem! Existem outras ferramentas com + sincronização automática que podem funcionar melhor pra você. +

+
+
+
+
- {/* CTA Section */} -
-
-
-

- Pronto para testar? -

-

- Clone o repositório, rode localmente e veja se faz sentido pra - você. É open source e gratuito. -

-
- - - - - - -
-
-
-
+ {/* CTA Section */} +
+
+
+

+ Pronto para testar? +

+

+ Clone o repositório, rode localmente e veja se faz sentido pra + você. É open source e gratuito. +

+
+ + + + + + +
+
+
+
- {/* Footer */} -
-
-
-
-
- -

- Projeto pessoal de gestão financeira. Open source e - self-hosted. -

-
+ {/* Footer */} +
+
+
+
+
+ +

+ Projeto pessoal de gestão financeira. Open source e + self-hosted. +

+
-
-

Projeto

-
    -
  • - - - GitHub - -
  • -
  • - - Documentação - -
  • -
  • - - Reportar Bug - -
  • -
-
+
+

Projeto

+
    +
  • + + + GitHub + +
  • +
  • + + Documentação + +
  • +
  • + + Reportar Bug + +
  • +
+
-
-

Stack

-
    -
  • Next.js 16 + TypeScript
  • -
  • PostgreSQL 18 + Drizzle ORM
  • -
  • Better Auth + shadcn/ui
  • -
  • Docker + Docker Compose
  • -
-
-
+
+

Stack

+
    +
  • Next.js 16 + TypeScript
  • +
  • PostgreSQL 18 + Drizzle ORM
  • +
  • Better Auth + shadcn/ui
  • +
  • Docker + Docker Compose
  • +
+
+
-
-

- © {new Date().getFullYear()} opensheets. Projeto open source sob - licença MIT. -

-
- - Seus dados, seu servidor -
-
-
-
-
- - ); +
+

+ © {new Date().getFullYear()} opensheets. Projeto open source sob + licença MIT. +

+
+ + Seus dados, seu servidor +
+
+ + + + + ); } diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index b2c1b51..5f87d57 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth/config"; import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/lib/auth/config"; export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/app/api/auth/device/refresh/route.ts b/app/api/auth/device/refresh/route.ts index 44a148c..7ab9f09 100644 --- a/app/api/auth/device/refresh/route.ts +++ b/app/api/auth/device/refresh/route.ts @@ -5,81 +5,88 @@ * Usado pelo app Android quando o access token expira. */ -import { refreshAccessToken, extractBearerToken, verifyJwt, hashToken } from "@/lib/auth/api-token"; -import { db } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; -import { eq, and, isNull } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; +import { apiTokens } from "@/db/schema"; +import { + extractBearerToken, + hashToken, + refreshAccessToken, + verifyJwt, +} from "@/lib/auth/api-token"; +import { db } from "@/lib/db"; export async function POST(request: Request) { - try { - // Extrair refresh token do header - const authHeader = request.headers.get("Authorization"); - const token = extractBearerToken(authHeader); + try { + // Extrair refresh token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); - if (!token) { - return NextResponse.json( - { error: "Refresh token não fornecido" }, - { status: 401 } - ); - } + if (!token) { + return NextResponse.json( + { error: "Refresh token não fornecido" }, + { status: 401 }, + ); + } - // Validar refresh token - const payload = verifyJwt(token); + // Validar refresh token + const payload = verifyJwt(token); - if (!payload || payload.type !== "api_refresh") { - return NextResponse.json( - { error: "Refresh token inválido ou expirado" }, - { status: 401 } - ); - } + if (!payload || payload.type !== "api_refresh") { + return NextResponse.json( + { error: "Refresh token inválido ou expirado" }, + { status: 401 }, + ); + } - // Verificar se token não foi revogado - const tokenRecord = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.id, payload.tokenId), - eq(apiTokens.userId, payload.sub), - isNull(apiTokens.revokedAt) - ), - }); + // Verificar se token não foi revogado + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.id, payload.tokenId), + eq(apiTokens.userId, payload.sub), + isNull(apiTokens.revokedAt), + ), + }); - if (!tokenRecord) { - return NextResponse.json( - { error: "Token revogado ou não encontrado" }, - { status: 401 } - ); - } + if (!tokenRecord) { + return NextResponse.json( + { error: "Token revogado ou não encontrado" }, + { status: 401 }, + ); + } - // Gerar novo access token - const result = refreshAccessToken(token); + // Gerar novo access token + const result = refreshAccessToken(token); - if (!result) { - return NextResponse.json( - { error: "Não foi possível renovar o token" }, - { status: 401 } - ); - } + if (!result) { + return NextResponse.json( + { error: "Não foi possível renovar o token" }, + { status: 401 }, + ); + } - // Atualizar hash do token e último uso - await db - .update(apiTokens) - .set({ - tokenHash: hashToken(result.accessToken), - lastUsedAt: new Date(), - lastUsedIp: request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip"), - expiresAt: result.expiresAt, - }) - .where(eq(apiTokens.id, payload.tokenId)); + // Atualizar hash do token e último uso + await db + .update(apiTokens) + .set({ + tokenHash: hashToken(result.accessToken), + lastUsedAt: new Date(), + lastUsedIp: + request.headers.get("x-forwarded-for") || + request.headers.get("x-real-ip"), + expiresAt: result.expiresAt, + }) + .where(eq(apiTokens.id, payload.tokenId)); - return NextResponse.json({ - accessToken: result.accessToken, - expiresAt: result.expiresAt.toISOString(), - }); - } catch (error) { - console.error("[API] Error refreshing device token:", error); - return NextResponse.json( - { error: "Erro ao renovar token" }, - { status: 500 } - ); - } + return NextResponse.json({ + accessToken: result.accessToken, + expiresAt: result.expiresAt.toISOString(), + }); + } catch (error) { + console.error("[API] Error refreshing device token:", error); + return NextResponse.json( + { error: "Erro ao renovar token" }, + { status: 500 }, + ); + } } diff --git a/app/api/auth/device/token/route.ts b/app/api/auth/device/token/route.ts index 6e7dab8..b494203 100644 --- a/app/api/auth/device/token/route.ts +++ b/app/api/auth/device/token/route.ts @@ -5,75 +5,74 @@ * Requer sessão web autenticada. */ -import { auth } from "@/lib/auth/config"; -import { generateTokenPair, hashToken, getTokenPrefix } from "@/lib/auth/api-token"; -import { db } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { z } from "zod"; +import { apiTokens } from "@/db/schema"; +import { + generateTokenPair, + getTokenPrefix, + hashToken, +} from "@/lib/auth/api-token"; +import { auth } from "@/lib/auth/config"; +import { db } from "@/lib/db"; const createTokenSchema = z.object({ - name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"), - deviceId: z.string().optional(), + name: z.string().min(1, "Nome é obrigatório").max(100, "Nome muito longo"), + deviceId: z.string().optional(), }); export async function POST(request: Request) { - try { - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); + try { + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { - return NextResponse.json( - { error: "Não autenticado" }, - { status: 401 } - ); - } + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } - // Validar body - const body = await request.json(); - const { name, deviceId } = createTokenSchema.parse(body); + // Validar body + const body = await request.json(); + const { name, deviceId } = createTokenSchema.parse(body); - // Gerar par de tokens - const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair( - session.user.id, - deviceId - ); + // Gerar par de tokens + const { accessToken, refreshToken, tokenId, expiresAt } = generateTokenPair( + session.user.id, + deviceId, + ); - // Salvar hash do token no banco - await db.insert(apiTokens).values({ - id: tokenId, - userId: session.user.id, - name, - tokenHash: hashToken(accessToken), - tokenPrefix: getTokenPrefix(accessToken), - expiresAt, - }); + // Salvar hash do token no banco + await db.insert(apiTokens).values({ + id: tokenId, + userId: session.user.id, + name, + tokenHash: hashToken(accessToken), + tokenPrefix: getTokenPrefix(accessToken), + expiresAt, + }); - // Retornar tokens (mostrados apenas uma vez) - return NextResponse.json( - { - accessToken, - refreshToken, - tokenId, - name, - expiresAt: expiresAt.toISOString(), - message: "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.", - }, - { status: 201 } - ); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.issues[0]?.message ?? "Dados inválidos" }, - { status: 400 } - ); - } + // Retornar tokens (mostrados apenas uma vez) + return NextResponse.json( + { + accessToken, + refreshToken, + tokenId, + name, + expiresAt: expiresAt.toISOString(), + message: + "Token criado com sucesso. Guarde-o em local seguro, ele não será mostrado novamente.", + }, + { status: 201 }, + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message ?? "Dados inválidos" }, + { status: 400 }, + ); + } - console.error("[API] Error creating device token:", error); - return NextResponse.json( - { error: "Erro ao criar token" }, - { status: 500 } - ); - } + console.error("[API] Error creating device token:", error); + return NextResponse.json({ error: "Erro ao criar token" }, { status: 500 }); + } } diff --git a/app/api/auth/device/tokens/[tokenId]/route.ts b/app/api/auth/device/tokens/[tokenId]/route.ts index c6c64e9..5758bf4 100644 --- a/app/api/auth/device/tokens/[tokenId]/route.ts +++ b/app/api/auth/device/tokens/[tokenId]/route.ts @@ -5,61 +5,58 @@ * Requer sessão web autenticada. */ -import { auth } from "@/lib/auth/config"; -import { db } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; -import { eq, and } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; +import { apiTokens } from "@/db/schema"; +import { auth } from "@/lib/auth/config"; +import { db } from "@/lib/db"; interface RouteParams { - params: Promise<{ tokenId: string }>; + params: Promise<{ tokenId: string }>; } -export async function DELETE(request: Request, { params }: RouteParams) { - try { - const { tokenId } = await params; +export async function DELETE(_request: Request, { params }: RouteParams) { + try { + const { tokenId } = await params; - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { - return NextResponse.json( - { error: "Não autenticado" }, - { status: 401 } - ); - } + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } - // Verificar se token pertence ao usuário - const token = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.id, tokenId), - eq(apiTokens.userId, session.user.id) - ), - }); + // Verificar se token pertence ao usuário + const token = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.id, tokenId), + eq(apiTokens.userId, session.user.id), + ), + }); - if (!token) { - return NextResponse.json( - { error: "Token não encontrado" }, - { status: 404 } - ); - } + if (!token) { + return NextResponse.json( + { error: "Token não encontrado" }, + { status: 404 }, + ); + } - // Revogar token (soft delete) - await db - .update(apiTokens) - .set({ revokedAt: new Date() }) - .where(eq(apiTokens.id, tokenId)); + // Revogar token (soft delete) + await db + .update(apiTokens) + .set({ revokedAt: new Date() }) + .where(eq(apiTokens.id, tokenId)); - return NextResponse.json({ - message: "Token revogado com sucesso", - tokenId, - }); - } catch (error) { - console.error("[API] Error revoking device token:", error); - return NextResponse.json( - { error: "Erro ao revogar token" }, - { status: 500 } - ); - } + return NextResponse.json({ + message: "Token revogado com sucesso", + tokenId, + }); + } catch (error) { + console.error("[API] Error revoking device token:", error); + return NextResponse.json( + { error: "Erro ao revogar token" }, + { status: 500 }, + ); + } } diff --git a/app/api/auth/device/tokens/route.ts b/app/api/auth/device/tokens/route.ts index 340ed9b..e57770f 100644 --- a/app/api/auth/device/tokens/route.ts +++ b/app/api/auth/device/tokens/route.ts @@ -5,49 +5,48 @@ * Requer sessão web autenticada. */ -import { auth } from "@/lib/auth/config"; -import { db } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; -import { eq, desc } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; +import { apiTokens } from "@/db/schema"; +import { auth } from "@/lib/auth/config"; +import { db } from "@/lib/db"; export async function GET() { - try { - // Verificar autenticação via sessão web - const session = await auth.api.getSession({ headers: await headers() }); + try { + // Verificar autenticação via sessão web + const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { - return NextResponse.json( - { error: "Não autenticado" }, - { status: 401 } - ); - } + if (!session?.user) { + return NextResponse.json({ error: "Não autenticado" }, { status: 401 }); + } - // Buscar tokens ativos do usuário - const tokens = await db - .select({ - id: apiTokens.id, - name: apiTokens.name, - tokenPrefix: apiTokens.tokenPrefix, - lastUsedAt: apiTokens.lastUsedAt, - lastUsedIp: apiTokens.lastUsedIp, - expiresAt: apiTokens.expiresAt, - createdAt: apiTokens.createdAt, - }) - .from(apiTokens) - .where(eq(apiTokens.userId, session.user.id)) - .orderBy(desc(apiTokens.createdAt)); + // Buscar tokens ativos do usuário + const tokens = await db + .select({ + id: apiTokens.id, + name: apiTokens.name, + tokenPrefix: apiTokens.tokenPrefix, + lastUsedAt: apiTokens.lastUsedAt, + lastUsedIp: apiTokens.lastUsedIp, + expiresAt: apiTokens.expiresAt, + createdAt: apiTokens.createdAt, + }) + .from(apiTokens) + .where(eq(apiTokens.userId, session.user.id)) + .orderBy(desc(apiTokens.createdAt)); - // Separar tokens ativos e revogados - const activeTokens = tokens.filter((t) => !t.expiresAt || new Date(t.expiresAt) > new Date()); + // Separar tokens ativos e revogados + const activeTokens = tokens.filter( + (t) => !t.expiresAt || new Date(t.expiresAt) > new Date(), + ); - return NextResponse.json({ tokens: activeTokens }); - } catch (error) { - console.error("[API] Error listing device tokens:", error); - return NextResponse.json( - { error: "Erro ao listar tokens" }, - { status: 500 } - ); - } + return NextResponse.json({ tokens: activeTokens }); + } catch (error) { + console.error("[API] Error listing device tokens:", error); + return NextResponse.json( + { error: "Erro ao listar tokens" }, + { status: 500 }, + ); + } } diff --git a/app/api/auth/device/verify/route.ts b/app/api/auth/device/verify/route.ts index 8af7767..7539e9b 100644 --- a/app/api/auth/device/verify/route.ts +++ b/app/api/auth/device/verify/route.ts @@ -7,75 +7,76 @@ * Aceita tokens no formato os_xxx (hash-based, sem expiração). */ +import { and, eq, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { apiTokens } from "@/db/schema"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; -import { apiTokens } from "@/db/schema"; -import { eq, and, isNull } from "drizzle-orm"; -import { NextResponse } from "next/server"; export async function POST(request: Request) { - try { - // Extrair token do header - const authHeader = request.headers.get("Authorization"); - const token = extractBearerToken(authHeader); + try { + // Extrair token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); - if (!token) { - return NextResponse.json( - { valid: false, error: "Token não fornecido" }, - { status: 401 } - ); - } + if (!token) { + return NextResponse.json( + { valid: false, error: "Token não fornecido" }, + { status: 401 }, + ); + } - // Validar token os_xxx via hash lookup - if (!token.startsWith("os_")) { - return NextResponse.json( - { valid: false, error: "Formato de token inválido" }, - { status: 401 } - ); - } + // Validar token os_xxx via hash lookup + if (!token.startsWith("os_")) { + return NextResponse.json( + { valid: false, error: "Formato de token inválido" }, + { status: 401 }, + ); + } - // Hash do token para buscar no DB - const tokenHash = hashToken(token); + // Hash do token para buscar no DB + const tokenHash = hashToken(token); - // Buscar token no banco - const tokenRecord = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.tokenHash, tokenHash), - isNull(apiTokens.revokedAt) - ), - }); + // Buscar token no banco + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.tokenHash, tokenHash), + isNull(apiTokens.revokedAt), + ), + }); - if (!tokenRecord) { - return NextResponse.json( - { valid: false, error: "Token inválido ou revogado" }, - { status: 401 } - ); - } + if (!tokenRecord) { + return NextResponse.json( + { valid: false, error: "Token inválido ou revogado" }, + { status: 401 }, + ); + } - // Atualizar último uso - const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() - || request.headers.get("x-real-ip") - || null; + // Atualizar último uso + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + null; - await db - .update(apiTokens) - .set({ - lastUsedAt: new Date(), - lastUsedIp: clientIp, - }) - .where(eq(apiTokens.id, tokenRecord.id)); + await db + .update(apiTokens) + .set({ + lastUsedAt: new Date(), + lastUsedIp: clientIp, + }) + .where(eq(apiTokens.id, tokenRecord.id)); - return NextResponse.json({ - valid: true, - userId: tokenRecord.userId, - tokenId: tokenRecord.id, - tokenName: tokenRecord.name, - }); - } catch (error) { - console.error("[API] Error verifying device token:", error); - return NextResponse.json( - { valid: false, error: "Erro ao validar token" }, - { status: 500 } - ); - } + return NextResponse.json({ + valid: true, + userId: tokenRecord.userId, + tokenId: tokenRecord.id, + tokenName: tokenRecord.name, + }); + } catch (error) { + console.error("[API] Error verifying device token:", error); + return NextResponse.json( + { valid: false, error: "Erro ao validar token" }, + { status: 500 }, + ); + } } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 7d129ff..32e8a8c 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -12,33 +12,34 @@ const APP_VERSION = "1.0.0"; * Usado pelo app Android para validar URL do servidor */ export async function GET() { - try { - // Tenta fazer uma query simples no banco para verificar conexão - // Isso garante que o app está conectado ao banco antes de considerar "healthy" - await db.execute("SELECT 1"); + try { + // Tenta fazer uma query simples no banco para verificar conexão + // Isso garante que o app está conectado ao banco antes de considerar "healthy" + await db.execute("SELECT 1"); - return NextResponse.json( - { - status: "ok", - name: "OpenSheets", - version: APP_VERSION, - timestamp: new Date().toISOString(), - }, - { status: 200 } - ); - } catch (error) { - // Se houver erro na conexão com banco, retorna status 503 (Service Unavailable) - console.error("Health check failed:", error); + return NextResponse.json( + { + status: "ok", + name: "OpenSheets", + version: APP_VERSION, + timestamp: new Date().toISOString(), + }, + { status: 200 }, + ); + } catch (error) { + // Se houver erro na conexão com banco, retorna status 503 (Service Unavailable) + console.error("Health check failed:", error); - return NextResponse.json( - { - status: "error", - name: "OpenSheets", - version: APP_VERSION, - timestamp: new Date().toISOString(), - message: error instanceof Error ? error.message : "Database connection failed", - }, - { status: 503 } - ); - } + return NextResponse.json( + { + status: "error", + name: "OpenSheets", + version: APP_VERSION, + timestamp: new Date().toISOString(), + message: + error instanceof Error ? error.message : "Database connection failed", + }, + { status: 503 }, + ); + } } diff --git a/app/api/inbox/batch/route.ts b/app/api/inbox/batch/route.ts index 58e57be..65421d6 100644 --- a/app/api/inbox/batch/route.ts +++ b/app/api/inbox/batch/route.ts @@ -5,13 +5,13 @@ * Requer autenticação via API token (formato os_xxx). */ +import { and, eq, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; import { apiTokens, inboxItems } from "@/db/schema"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; import { inboxBatchSchema } from "@/lib/schemas/inbox"; -import { and, eq, isNull } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { z } from "zod"; // Rate limiting simples em memória const rateLimitMap = new Map(); @@ -19,153 +19,153 @@ const RATE_LIMIT = 20; // 20 batch requests const RATE_WINDOW = 60 * 1000; // por minuto function checkRateLimit(userId: string): boolean { - const now = Date.now(); - const userLimit = rateLimitMap.get(userId); + const now = Date.now(); + const userLimit = rateLimitMap.get(userId); - if (!userLimit || userLimit.resetAt < now) { - rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW }); - return true; - } + if (!userLimit || userLimit.resetAt < now) { + rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW }); + return true; + } - if (userLimit.count >= RATE_LIMIT) { - return false; - } + if (userLimit.count >= RATE_LIMIT) { + return false; + } - userLimit.count++; - return true; + userLimit.count++; + return true; } interface BatchResult { - clientId?: string; - serverId?: string; - success: boolean; - error?: string; + clientId?: string; + serverId?: string; + success: boolean; + error?: string; } export async function POST(request: Request) { - try { - // Extrair token do header - const authHeader = request.headers.get("Authorization"); - const token = extractBearerToken(authHeader); + try { + // Extrair token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); - if (!token) { - return NextResponse.json( - { error: "Token não fornecido" }, - { status: 401 }, - ); - } + if (!token) { + return NextResponse.json( + { error: "Token não fornecido" }, + { status: 401 }, + ); + } - // Validar token os_xxx via hash - if (!token.startsWith("os_")) { - return NextResponse.json( - { error: "Formato de token inválido" }, - { status: 401 }, - ); - } + // Validar token os_xxx via hash + if (!token.startsWith("os_")) { + return NextResponse.json( + { error: "Formato de token inválido" }, + { status: 401 }, + ); + } - const tokenHash = hashToken(token); + const tokenHash = hashToken(token); - // Buscar token no banco - const tokenRecord = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.tokenHash, tokenHash), - isNull(apiTokens.revokedAt), - ), - }); + // Buscar token no banco + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.tokenHash, tokenHash), + isNull(apiTokens.revokedAt), + ), + }); - if (!tokenRecord) { - return NextResponse.json( - { error: "Token inválido ou revogado" }, - { status: 401 }, - ); - } + if (!tokenRecord) { + return NextResponse.json( + { error: "Token inválido ou revogado" }, + { status: 401 }, + ); + } - // Rate limiting - if (!checkRateLimit(tokenRecord.userId)) { - return NextResponse.json( - { error: "Limite de requisições excedido", retryAfter: 60 }, - { status: 429 }, - ); - } + // Rate limiting + if (!checkRateLimit(tokenRecord.userId)) { + return NextResponse.json( + { error: "Limite de requisições excedido", retryAfter: 60 }, + { status: 429 }, + ); + } - // Validar body - const body = await request.json(); - const { items } = inboxBatchSchema.parse(body); + // Validar body + const body = await request.json(); + const { items } = inboxBatchSchema.parse(body); - // Processar cada item - const results: BatchResult[] = []; + // Processar cada item + const results: BatchResult[] = []; - for (const item of items) { - try { - const [inserted] = await db - .insert(inboxItems) - .values({ - userId: tokenRecord.userId, - sourceApp: item.sourceApp, - sourceAppName: item.sourceAppName, - originalTitle: item.originalTitle, - originalText: item.originalText, - notificationTimestamp: item.notificationTimestamp, - parsedName: item.parsedName, - parsedAmount: item.parsedAmount?.toString(), - parsedTransactionType: item.parsedTransactionType, - status: "pending", - }) - .returning({ id: inboxItems.id }); + for (const item of items) { + try { + const [inserted] = await db + .insert(inboxItems) + .values({ + userId: tokenRecord.userId, + sourceApp: item.sourceApp, + sourceAppName: item.sourceAppName, + originalTitle: item.originalTitle, + originalText: item.originalText, + notificationTimestamp: item.notificationTimestamp, + parsedName: item.parsedName, + parsedAmount: item.parsedAmount?.toString(), + parsedTransactionType: item.parsedTransactionType, + status: "pending", + }) + .returning({ id: inboxItems.id }); - results.push({ - clientId: item.clientId, - serverId: inserted.id, - success: true, - }); - } catch (error) { - results.push({ - clientId: item.clientId, - success: false, - error: error instanceof Error ? error.message : "Erro desconhecido", - }); - } - } + results.push({ + clientId: item.clientId, + serverId: inserted.id, + success: true, + }); + } catch (error) { + results.push({ + clientId: item.clientId, + success: false, + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + } + } - // Atualizar último uso do token - const clientIp = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null; + // Atualizar último uso do token + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + null; - await db - .update(apiTokens) - .set({ - lastUsedAt: new Date(), - lastUsedIp: clientIp, - }) - .where(eq(apiTokens.id, tokenRecord.id)); + await db + .update(apiTokens) + .set({ + lastUsedAt: new Date(), + lastUsedIp: clientIp, + }) + .where(eq(apiTokens.id, tokenRecord.id)); - const successCount = results.filter((r) => r.success).length; - const failCount = results.filter((r) => !r.success).length; + const successCount = results.filter((r) => r.success).length; + const failCount = results.filter((r) => !r.success).length; - return NextResponse.json( - { - message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`, - total: items.length, - success: successCount, - failed: failCount, - results, - }, - { status: 201 }, - ); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.issues[0]?.message ?? "Dados inválidos" }, - { status: 400 }, - ); - } + return NextResponse.json( + { + message: `${successCount} notificações processadas${failCount > 0 ? `, ${failCount} falharam` : ""}`, + total: items.length, + success: successCount, + failed: failCount, + results, + }, + { status: 201 }, + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message ?? "Dados inválidos" }, + { status: 400 }, + ); + } - console.error("[API] Error creating batch inbox items:", error); - return NextResponse.json( - { error: "Erro ao processar notificações" }, - { status: 500 }, - ); - } + console.error("[API] Error creating batch inbox items:", error); + return NextResponse.json( + { error: "Erro ao processar notificações" }, + { status: 500 }, + ); + } } diff --git a/app/api/inbox/route.ts b/app/api/inbox/route.ts index a13f8c9..03af77f 100644 --- a/app/api/inbox/route.ts +++ b/app/api/inbox/route.ts @@ -5,13 +5,13 @@ * Requer autenticação via API token (formato os_xxx). */ +import { and, eq, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; import { apiTokens, inboxItems } from "@/db/schema"; import { extractBearerToken, hashToken } from "@/lib/auth/api-token"; import { db } from "@/lib/db"; import { inboxItemSchema } from "@/lib/schemas/inbox"; -import { and, eq, isNull } from "drizzle-orm"; -import { NextResponse } from "next/server"; -import { z } from "zod"; // Rate limiting simples em memória (em produção, use Redis) const rateLimitMap = new Map(); @@ -19,123 +19,123 @@ const RATE_LIMIT = 100; // 100 requests const RATE_WINDOW = 60 * 1000; // por minuto function checkRateLimit(userId: string): boolean { - const now = Date.now(); - const userLimit = rateLimitMap.get(userId); + const now = Date.now(); + const userLimit = rateLimitMap.get(userId); - if (!userLimit || userLimit.resetAt < now) { - rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW }); - return true; - } + if (!userLimit || userLimit.resetAt < now) { + rateLimitMap.set(userId, { count: 1, resetAt: now + RATE_WINDOW }); + return true; + } - if (userLimit.count >= RATE_LIMIT) { - return false; - } + if (userLimit.count >= RATE_LIMIT) { + return false; + } - userLimit.count++; - return true; + userLimit.count++; + return true; } export async function POST(request: Request) { - try { - // Extrair token do header - const authHeader = request.headers.get("Authorization"); - const token = extractBearerToken(authHeader); + try { + // Extrair token do header + const authHeader = request.headers.get("Authorization"); + const token = extractBearerToken(authHeader); - if (!token) { - return NextResponse.json( - { error: "Token não fornecido" }, - { status: 401 }, - ); - } + if (!token) { + return NextResponse.json( + { error: "Token não fornecido" }, + { status: 401 }, + ); + } - // Validar token os_xxx via hash - if (!token.startsWith("os_")) { - return NextResponse.json( - { error: "Formato de token inválido" }, - { status: 401 }, - ); - } + // Validar token os_xxx via hash + if (!token.startsWith("os_")) { + return NextResponse.json( + { error: "Formato de token inválido" }, + { status: 401 }, + ); + } - const tokenHash = hashToken(token); + const tokenHash = hashToken(token); - // Buscar token no banco - const tokenRecord = await db.query.apiTokens.findFirst({ - where: and( - eq(apiTokens.tokenHash, tokenHash), - isNull(apiTokens.revokedAt), - ), - }); + // Buscar token no banco + const tokenRecord = await db.query.apiTokens.findFirst({ + where: and( + eq(apiTokens.tokenHash, tokenHash), + isNull(apiTokens.revokedAt), + ), + }); - if (!tokenRecord) { - return NextResponse.json( - { error: "Token inválido ou revogado" }, - { status: 401 }, - ); - } + if (!tokenRecord) { + return NextResponse.json( + { error: "Token inválido ou revogado" }, + { status: 401 }, + ); + } - // Rate limiting - if (!checkRateLimit(tokenRecord.userId)) { - return NextResponse.json( - { error: "Limite de requisições excedido", retryAfter: 60 }, - { status: 429 }, - ); - } + // Rate limiting + if (!checkRateLimit(tokenRecord.userId)) { + return NextResponse.json( + { error: "Limite de requisições excedido", retryAfter: 60 }, + { status: 429 }, + ); + } - // Validar body - const body = await request.json(); - const data = inboxItemSchema.parse(body); + // Validar body + const body = await request.json(); + const data = inboxItemSchema.parse(body); - // Inserir item na inbox - const [inserted] = await db - .insert(inboxItems) - .values({ - userId: tokenRecord.userId, - sourceApp: data.sourceApp, - sourceAppName: data.sourceAppName, - originalTitle: data.originalTitle, - originalText: data.originalText, - notificationTimestamp: data.notificationTimestamp, - parsedName: data.parsedName, - parsedAmount: data.parsedAmount?.toString(), - parsedTransactionType: data.parsedTransactionType, - status: "pending", - }) - .returning({ id: inboxItems.id }); + // Inserir item na inbox + const [inserted] = await db + .insert(inboxItems) + .values({ + userId: tokenRecord.userId, + sourceApp: data.sourceApp, + sourceAppName: data.sourceAppName, + originalTitle: data.originalTitle, + originalText: data.originalText, + notificationTimestamp: data.notificationTimestamp, + parsedName: data.parsedName, + parsedAmount: data.parsedAmount?.toString(), + parsedTransactionType: data.parsedTransactionType, + status: "pending", + }) + .returning({ id: inboxItems.id }); - // Atualizar último uso do token - const clientIp = - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null; + // Atualizar último uso do token + const clientIp = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + null; - await db - .update(apiTokens) - .set({ - lastUsedAt: new Date(), - lastUsedIp: clientIp, - }) - .where(eq(apiTokens.id, tokenRecord.id)); + await db + .update(apiTokens) + .set({ + lastUsedAt: new Date(), + lastUsedIp: clientIp, + }) + .where(eq(apiTokens.id, tokenRecord.id)); - return NextResponse.json( - { - id: inserted.id, - clientId: data.clientId, - message: "Notificação recebida", - }, - { status: 201 }, - ); - } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: error.issues[0]?.message ?? "Dados inválidos" }, - { status: 400 }, - ); - } + return NextResponse.json( + { + id: inserted.id, + clientId: data.clientId, + message: "Notificação recebida", + }, + { status: 201 }, + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: error.issues[0]?.message ?? "Dados inválidos" }, + { status: 400 }, + ); + } - console.error("[API] Error creating inbox item:", error); - return NextResponse.json( - { error: "Erro ao processar notificação" }, - { status: 500 }, - ); - } + console.error("[API] Error creating inbox item:", error); + return NextResponse.json( + { error: "Erro ao processar notificação" }, + { status: 500 }, + ); + } } diff --git a/app/error.tsx b/app/error.tsx index acbf006..5046826 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -6,48 +6,48 @@ import { useEffect } from "react"; import { Button } from "@/components/ui/button"; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, } from "@/components/ui/empty"; export default function Error({ - error, - reset, + error, + reset, }: { - error: Error & { digest?: string }; - reset: () => void; + error: Error & { digest?: string }; + reset: () => void; }) { - useEffect(() => { - // Log the error to an error reporting service - console.error(error); - }, [error]); + useEffect(() => { + // Log the error to an error reporting service + console.error(error); + }, [error]); - return ( -
- - - - - - Algo deu errado - - Ocorreu um problema inesperado. Por favor, tente novamente ou volte - para o dashboard. - - - -
- - -
-
-
-
- ); + return ( +
+ + + + + + Algo deu errado + + Ocorreu um problema inesperado. Por favor, tente novamente ou volte + para o dashboard. + + + +
+ + +
+
+
+
+ ); } diff --git a/app/layout.tsx b/app/layout.tsx index b3fddd0..434e6bf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,37 +1,37 @@ -import { ThemeProvider } from "@/components/theme-provider"; -import { Toaster } from "@/components/ui/sonner"; -import { main_font } from "@/public/fonts/font_index"; import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { main_font } from "@/public/fonts/font_index"; import "./globals.css"; export const metadata: Metadata = { - title: "Opensheets", - description: "Finanças pessoais descomplicadas.", + title: "Opensheets", + description: "Finanças pessoais descomplicadas.", }; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - - - - - {children} - - - - - - - ); + return ( + + + + + + + {children} + + + + + + + ); } diff --git a/app/manifest.json b/app/manifest.json index cefeef7..7f5006f 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -1,21 +1,21 @@ { - "name": "Opensheets", - "short_name": "Opensheets", - "icons": [ - { - "src": "/web-app-manifest-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/web-app-manifest-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ], - "theme_color": "#F2ECE7", - "background_color": "#F2ECE7", - "display": "standalone" -} \ No newline at end of file + "name": "Opensheets", + "short_name": "Opensheets", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#F2ECE7", + "background_color": "#F2ECE7", + "display": "standalone" +} diff --git a/app/not-found.tsx b/app/not-found.tsx index 9bbd750..0959f78 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,35 +1,35 @@ -import Link from "next/link"; import { RiFileSearchLine } from "@remixicon/react"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, } from "@/components/ui/empty"; export default function NotFound() { - return ( -
- - - - - - Página não encontrada - - A página que você está procurando não existe ou foi movida. - - - - - - -
- ); + return ( +
+ + + + + + Página não encontrada + + A página que você está procurando não existe ou foi movida. + + + + + + +
+ ); } diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..603cf9c --- /dev/null +++ b/biome.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noArrayIndexKey": "off", + "noExplicitAny": "warn", + "noImplicitAnyLet": "warn", + "noShadowRestrictedNames": "warn", + "noDocumentCookie": "off", + "useIterableCallbackReturn": "off" + }, + "style": { + "noNonNullAssertion": "warn" + }, + "a11y": { + "noLabelWithoutControl": "off", + "useFocusableInteractive": "off", + "useSemanticElements": "off", + "noSvgWithoutTitle": "off", + "useButtonType": "off", + "useAriaPropsSupportedByRole": "off" + }, + "correctness": { + "noUnusedVariables": "warn", + "noUnusedFunctionParameters": "off", + "noInvalidUseBeforeDeclaration": "warn", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "warn" + }, + "security": { + "noDangerouslySetInnerHtml": "off" + }, + "performance": { + "noImgElement": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/components.json b/components.json index 13a92f1..186b843 100644 --- a/components.json +++ b/components.json @@ -1,26 +1,26 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "@remixicon/react", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": { - "@coss": "https://coss.com/ui/r/{name}.json", - "@magicui": "https://magicui.design/r/{name}.json", - "@react-bits": "https://reactbits.dev/r/{name}.json" - } + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "@remixicon/react", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@coss": "https://coss.com/ui/r/{name}.json", + "@magicui": "https://magicui.design/r/{name}.json", + "@react-bits": "https://reactbits.dev/r/{name}.json" + } } diff --git a/components/ajustes/api-tokens-form.tsx b/components/ajustes/api-tokens-form.tsx index c540d4d..f46d191 100644 --- a/components/ajustes/api-tokens-form.tsx +++ b/components/ajustes/api-tokens-form.tsx @@ -1,335 +1,352 @@ "use client"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { - RiSmartphoneLine, - RiDeleteBinLine, - RiAddLine, - RiFileCopyLine, - RiCheckLine, - RiAlertLine, + RiAddLine, + RiAlertLine, + RiCheckLine, + RiDeleteBinLine, + RiFileCopyLine, + RiSmartphoneLine, } from "@remixicon/react"; import { formatDistanceToNow } from "date-fns"; import { ptBR } from "date-fns/locale"; -import { createApiTokenAction, revokeApiTokenAction } from "@/app/(dashboard)/ajustes/actions"; +import { useState } from "react"; +import { + createApiTokenAction, + revokeApiTokenAction, +} from "@/app/(dashboard)/ajustes/actions"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; interface ApiToken { - id: string; - name: string; - tokenPrefix: string; - lastUsedAt: Date | null; - lastUsedIp: string | null; - createdAt: Date; - expiresAt: Date | null; - revokedAt: Date | null; + id: string; + name: string; + tokenPrefix: string; + lastUsedAt: Date | null; + lastUsedIp: string | null; + createdAt: Date; + expiresAt: Date | null; + revokedAt: Date | null; } interface ApiTokensFormProps { - tokens: ApiToken[]; + tokens: ApiToken[]; } export function ApiTokensForm({ tokens }: ApiTokensFormProps) { - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [tokenName, setTokenName] = useState(""); - const [isCreating, setIsCreating] = useState(false); - const [newToken, setNewToken] = useState(null); - const [copied, setCopied] = useState(false); - const [revokeId, setRevokeId] = useState(null); - const [isRevoking, setIsRevoking] = useState(false); - const [error, setError] = useState(null); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [tokenName, setTokenName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + const [revokeId, setRevokeId] = useState(null); + const [isRevoking, setIsRevoking] = useState(false); + const [error, setError] = useState(null); - const activeTokens = tokens.filter((t) => !t.revokedAt); + const activeTokens = tokens.filter((t) => !t.revokedAt); - const handleCreate = async () => { - if (!tokenName.trim()) return; + const handleCreate = async () => { + if (!tokenName.trim()) return; - setIsCreating(true); - setError(null); + setIsCreating(true); + setError(null); - try { - const result = await createApiTokenAction({ name: tokenName.trim() }); + try { + const result = await createApiTokenAction({ name: tokenName.trim() }); - if (result.success && result.data?.token) { - setNewToken(result.data.token); - setTokenName(""); - } else { - setError(result.error || "Erro ao criar token"); - } - } catch { - setError("Erro ao criar token"); - } finally { - setIsCreating(false); - } - }; + if (result.success && result.data?.token) { + setNewToken(result.data.token); + setTokenName(""); + } else { + setError(result.error || "Erro ao criar token"); + } + } catch { + setError("Erro ao criar token"); + } finally { + setIsCreating(false); + } + }; - const handleCopy = async () => { - if (!newToken) return; + const handleCopy = async () => { + if (!newToken) return; - try { - await navigator.clipboard.writeText(newToken); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Fallback for browsers that don't support clipboard API - const textArea = document.createElement("textarea"); - textArea.value = newToken; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand("copy"); - document.body.removeChild(textArea); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; + try { + await navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for browsers that don't support clipboard API + const textArea = document.createElement("textarea"); + textArea.value = newToken; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; - const handleRevoke = async () => { - if (!revokeId) return; + const handleRevoke = async () => { + if (!revokeId) return; - setIsRevoking(true); + setIsRevoking(true); - try { - const result = await revokeApiTokenAction({ tokenId: revokeId }); + try { + const result = await revokeApiTokenAction({ tokenId: revokeId }); - if (!result.success) { - setError(result.error || "Erro ao revogar token"); - } - } catch { - setError("Erro ao revogar token"); - } finally { - setIsRevoking(false); - setRevokeId(null); - } - }; + if (!result.success) { + setError(result.error || "Erro ao revogar token"); + } + } catch { + setError("Erro ao revogar token"); + } finally { + setIsRevoking(false); + setRevokeId(null); + } + }; - const handleCloseCreate = () => { - setIsCreateOpen(false); - setNewToken(null); - setTokenName(""); - setError(null); - }; + const handleCloseCreate = () => { + setIsCreateOpen(false); + setNewToken(null); + setTokenName(""); + setError(null); + }; - return ( -
-
-
-

Dispositivos conectados

-

- Gerencie os dispositivos que podem enviar notificações para o OpenSheets. -

-
- { - if (!open) handleCloseCreate(); - else setIsCreateOpen(true); - }}> - - - - - {!newToken ? ( - <> - - Criar Token de API - - Crie um token para conectar o OpenSheets Companion no seu dispositivo Android. - - -
-
- - setTokenName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleCreate(); - }} - /> -
- {error && ( -
- - {error} -
- )} -
- - - - - - ) : ( - <> - - Token Criado - - Copie o token abaixo e cole no app OpenSheets Companion. Este token - não será exibido novamente. - - -
-
- -
- - -
-
-
-

Importante:

-
    -
  • Guarde este token em local seguro
  • -
  • Ele não será exibido novamente
  • -
  • Use-o para configurar o app Android
  • -
-
-
- - - - - )} -
-
-
+ return ( +
+
+
+

Dispositivos conectados

+

+ Gerencie os dispositivos que podem enviar notificações para o + OpenSheets. +

+
+ { + if (!open) handleCloseCreate(); + else setIsCreateOpen(true); + }} + > + + + + + {!newToken ? ( + <> + + Criar Token de API + + Crie um token para conectar o OpenSheets Companion no seu + dispositivo Android. + + +
+
+ + setTokenName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + /> +
+ {error && ( +
+ + {error} +
+ )} +
+ + + + + + ) : ( + <> + + Token Criado + + Copie o token abaixo e cole no app OpenSheets Companion. + Este token + não será exibido novamente. + + +
+
+ +
+ + +
+
+
+

Importante:

+
    +
  • Guarde este token em local seguro
  • +
  • Ele não será exibido novamente
  • +
  • Use-o para configurar o app Android
  • +
+
+
+ + + + + )} +
+
+
- {activeTokens.length === 0 ? ( - - - -

- Nenhum dispositivo conectado. -

-

- Crie um token para conectar o app OpenSheets Companion. -

-
-
- ) : ( -
- {activeTokens.map((token) => ( - - -
-
-
- -
-
-
- {token.name} - - {token.tokenPrefix}... - -
-
- {token.lastUsedAt ? ( - - Usado{" "} - {formatDistanceToNow(token.lastUsedAt, { - addSuffix: true, - locale: ptBR, - })} - {token.lastUsedIp && ( - - ({token.lastUsedIp}) - - )} - - ) : ( - Nunca usado - )} -
-
- Criado em{" "} - {new Date(token.createdAt).toLocaleDateString("pt-BR")} -
-
-
- -
-
-
- ))} -
- )} + {activeTokens.length === 0 ? ( + + + +

+ Nenhum dispositivo conectado. +

+

+ Crie um token para conectar o app OpenSheets Companion. +

+
+
+ ) : ( +
+ {activeTokens.map((token) => ( + + +
+
+
+ +
+
+
+ {token.name} + + {token.tokenPrefix}... + +
+
+ {token.lastUsedAt ? ( + + Usado{" "} + {formatDistanceToNow(token.lastUsedAt, { + addSuffix: true, + locale: ptBR, + })} + {token.lastUsedIp && ( + + ({token.lastUsedIp}) + + )} + + ) : ( + Nunca usado + )} +
+
+ Criado em{" "} + {new Date(token.createdAt).toLocaleDateString("pt-BR")} +
+
+
+ +
+
+
+ ))} +
+ )} - {/* Revoke Confirmation Dialog */} - !open && setRevokeId(null)}> - - - Revogar token? - - O dispositivo associado a este token será desconectado e não poderá mais - enviar notificações. Esta ação não pode ser desfeita. - - - - Cancelar - - {isRevoking ? "Revogando..." : "Revogar"} - - - - -
- ); + {/* Revoke Confirmation Dialog */} + !open && setRevokeId(null)} + > + + + Revogar token? + + O dispositivo associado a este token será desconectado e não + poderá mais enviar notificações. Esta ação não pode ser desfeita. + + + + + Cancelar + + + {isRevoking ? "Revogando..." : "Revogar"} + + + + +
+ ); } diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx index 3406e29..eb7c91b 100644 --- a/components/ajustes/delete-account-form.tsx +++ b/components/ajustes/delete-account-form.tsx @@ -1,136 +1,136 @@ "use client"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth/client"; -import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; export function DeleteAccountForm() { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [isModalOpen, setIsModalOpen] = useState(false); - const [confirmation, setConfirmation] = useState(""); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [confirmation, setConfirmation] = useState(""); - const handleDelete = () => { - startTransition(async () => { - const result = await deleteAccountAction({ - confirmation, - }); + const handleDelete = () => { + startTransition(async () => { + const result = await deleteAccountAction({ + confirmation, + }); - if (result.success) { - toast.success(result.message); - // Fazer logout e redirecionar para página de login - await authClient.signOut(); - router.push("/"); - } else { - toast.error(result.error); - } - }); - }; + if (result.success) { + toast.success(result.message); + // Fazer logout e redirecionar para página de login + await authClient.signOut(); + router.push("/"); + } else { + toast.error(result.error); + } + }); + }; - const handleOpenModal = () => { - setConfirmation(""); - setIsModalOpen(true); - }; + const handleOpenModal = () => { + setConfirmation(""); + setIsModalOpen(true); + }; - const handleCloseModal = () => { - if (isPending) return; - setConfirmation(""); - setIsModalOpen(false); - }; + const handleCloseModal = () => { + if (isPending) return; + setConfirmation(""); + setIsModalOpen(false); + }; - return ( - <> -
-
-
    -
  • Lançamentos, orçamentos e anotações
  • -
  • Contas, cartões e categorias
  • -
  • Pagadores (incluindo o pagador padrão)
  • -
  • Preferências e configurações
  • -
  • - Resumindo tudo, sua conta será permanentemente removida -
  • -
-
+ return ( + <> +
+
+
    +
  • Lançamentos, orçamentos e anotações
  • +
  • Contas, cartões e categorias
  • +
  • Pagadores (incluindo o pagador padrão)
  • +
  • Preferências e configurações
  • +
  • + Resumindo tudo, sua conta será permanentemente removida +
  • +
+
-
- -
-
+
+ +
+
- - { - if (isPending) e.preventDefault(); - }} - onPointerDownOutside={(e) => { - if (isPending) e.preventDefault(); - }} - > - - Você tem certeza? - - Essa ação não pode ser desfeita. Isso irá deletar permanentemente - sua conta e remover seus dados de nossos servidores. - - + + { + if (isPending) e.preventDefault(); + }} + onPointerDownOutside={(e) => { + if (isPending) e.preventDefault(); + }} + > + + Você tem certeza? + + Essa ação não pode ser desfeita. Isso irá deletar permanentemente + sua conta e remover seus dados de nossos servidores. + + -
-
- - setConfirmation(e.target.value)} - disabled={isPending} - placeholder="DELETAR" - autoComplete="off" - /> -
-
+
+
+ + setConfirmation(e.target.value)} + disabled={isPending} + placeholder="DELETAR" + autoComplete="off" + /> +
+
- - - - -
-
- - ); + + + + +
+
+ + ); } diff --git a/components/ajustes/preferences-form.tsx b/components/ajustes/preferences-form.tsx index 9cec038..839def6 100644 --- a/components/ajustes/preferences-form.tsx +++ b/components/ajustes/preferences-form.tsx @@ -1,78 +1,72 @@ "use client"; -import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { toast } from "sonner"; +import { updatePreferencesAction } from "@/app/(dashboard)/ajustes/actions"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; interface PreferencesFormProps { - disableMagnetlines: boolean; + disableMagnetlines: boolean; } -export function PreferencesForm({ - disableMagnetlines, -}: PreferencesFormProps) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [magnetlinesDisabled, setMagnetlinesDisabled] = - useState(disableMagnetlines); +export function PreferencesForm({ disableMagnetlines }: PreferencesFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [magnetlinesDisabled, setMagnetlinesDisabled] = + useState(disableMagnetlines); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); - startTransition(async () => { - const result = await updatePreferencesAction({ - disableMagnetlines: magnetlinesDisabled, - }); + startTransition(async () => { + const result = await updatePreferencesAction({ + disableMagnetlines: magnetlinesDisabled, + }); - if (result.success) { - toast.success(result.message); - // Recarregar a página para aplicar as mudanças nos componentes - router.refresh(); - // Forçar reload completo para garantir que os hooks re-executem - setTimeout(() => { - window.location.reload(); - }, 500); - } else { - toast.error(result.error); - } - }); - }; + if (result.success) { + toast.success(result.message); + // Recarregar a página para aplicar as mudanças nos componentes + router.refresh(); + // Forçar reload completo para garantir que os hooks re-executem + setTimeout(() => { + window.location.reload(); + }, 500); + } else { + toast.error(result.error); + } + }); + }; - return ( -
-
-
-
- -

- Remove o recurso de linhas magnéticas do sistema. Essa mudança - afeta a interface e interações visuais. -

-
- -
-
+ return ( + +
+
+
+ +

+ Remove o recurso de linhas magnéticas do sistema. Essa mudança + afeta a interface e interações visuais. +

+
+ +
+
-
- -
-
- ); +
+ +
+ + ); } diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx index 2d14b22..c5d6654 100644 --- a/components/ajustes/update-email-form.tsx +++ b/components/ajustes/update-email-form.tsx @@ -1,220 +1,248 @@ "use client"; +import { + RiCheckLine, + RiCloseLine, + RiEyeLine, + RiEyeOffLine, +} from "@remixicon/react"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; import { updateEmailAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { RiCheckLine, RiCloseLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react"; -import { useState, useTransition, useMemo } from "react"; -import { toast } from "sonner"; type UpdateEmailFormProps = { - currentEmail: string; - authProvider?: string; // 'google' | 'credential' | undefined + currentEmail: string; + authProvider?: string; // 'google' | 'credential' | undefined }; -export function UpdateEmailForm({ currentEmail, authProvider }: UpdateEmailFormProps) { - const [isPending, startTransition] = useTransition(); - const [password, setPassword] = useState(""); - const [newEmail, setNewEmail] = useState(""); - const [confirmEmail, setConfirmEmail] = useState(""); - const [showPassword, setShowPassword] = useState(false); +export function UpdateEmailForm({ + currentEmail, + authProvider, +}: UpdateEmailFormProps) { + const [isPending, startTransition] = useTransition(); + const [password, setPassword] = useState(""); + const [newEmail, setNewEmail] = useState(""); + const [confirmEmail, setConfirmEmail] = useState(""); + const [showPassword, setShowPassword] = useState(false); - // Verificar se o usuário usa login via Google (não precisa de senha) - const isGoogleAuth = authProvider === "google"; + // Verificar se o usuário usa login via Google (não precisa de senha) + const isGoogleAuth = authProvider === "google"; - // Validação em tempo real: e-mails coincidem - const emailsMatch = useMemo(() => { - if (!confirmEmail) return null; // Não mostrar erro se campo vazio - return newEmail.toLowerCase() === confirmEmail.toLowerCase(); - }, [newEmail, confirmEmail]); + // Validação em tempo real: e-mails coincidem + const emailsMatch = useMemo(() => { + if (!confirmEmail) return null; // Não mostrar erro se campo vazio + return newEmail.toLowerCase() === confirmEmail.toLowerCase(); + }, [newEmail, confirmEmail]); - // Validação: novo e-mail é diferente do atual - const isEmailDifferent = useMemo(() => { - if (!newEmail) return true; - return newEmail.toLowerCase() !== currentEmail.toLowerCase(); - }, [newEmail, currentEmail]); + // Validação: novo e-mail é diferente do atual + const isEmailDifferent = useMemo(() => { + if (!newEmail) return true; + return newEmail.toLowerCase() !== currentEmail.toLowerCase(); + }, [newEmail, currentEmail]); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); - // Validação frontend antes de enviar - if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) { - toast.error("Os e-mails não coincidem"); - return; - } + // Validação frontend antes de enviar + if (newEmail.toLowerCase() !== confirmEmail.toLowerCase()) { + toast.error("Os e-mails não coincidem"); + return; + } - if (newEmail.toLowerCase() === currentEmail.toLowerCase()) { - toast.error("O novo e-mail deve ser diferente do atual"); - return; - } + if (newEmail.toLowerCase() === currentEmail.toLowerCase()) { + toast.error("O novo e-mail deve ser diferente do atual"); + return; + } - startTransition(async () => { - const result = await updateEmailAction({ - password: isGoogleAuth ? undefined : password, - newEmail, - confirmEmail, - }); + startTransition(async () => { + const result = await updateEmailAction({ + password: isGoogleAuth ? undefined : password, + newEmail, + confirmEmail, + }); - if (result.success) { - toast.success(result.message); - setPassword(""); - setNewEmail(""); - setConfirmEmail(""); - } else { - toast.error(result.error); - } - }); - }; + if (result.success) { + toast.success(result.message); + setPassword(""); + setNewEmail(""); + setConfirmEmail(""); + } else { + toast.error(result.error); + } + }); + }; - return ( -
-
- {/* E-mail atual (apenas informativo) */} -
- - -

- Este é seu e-mail atual cadastrado -

-
+ return ( + +
+ {/* E-mail atual (apenas informativo) */} +
+ + +

+ Este é seu e-mail atual cadastrado +

+
- {/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */} - {!isGoogleAuth && ( -
- -
- setPassword(e.target.value)} - disabled={isPending} - placeholder="Digite sua senha para confirmar" - required - aria-required="true" - aria-describedby="password-help" - /> - -
-

- Por segurança, confirme sua senha antes de alterar seu e-mail -

-
- )} + {/* Senha de confirmação (apenas para usuários com login por e-mail/senha) */} + {!isGoogleAuth && ( +
+ +
+ setPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha para confirmar" + required + aria-required="true" + aria-describedby="password-help" + /> + +
+

+ Por segurança, confirme sua senha antes de alterar seu e-mail +

+
+ )} - {/* Novo e-mail */} -
- - setNewEmail(e.target.value)} - disabled={isPending} - placeholder="Digite o novo e-mail" - required - aria-required="true" - aria-describedby="new-email-help" - aria-invalid={!isEmailDifferent} - className={!isEmailDifferent ? "border-red-500 focus-visible:ring-red-500" : ""} - /> - {!isEmailDifferent && newEmail && ( -

- - O novo e-mail deve ser diferente do atual -

- )} - {!newEmail && ( -

- Digite o novo endereço de e-mail para sua conta -

- )} -
+ {/* Novo e-mail */} +
+ + setNewEmail(e.target.value)} + disabled={isPending} + placeholder="Digite o novo e-mail" + required + aria-required="true" + aria-describedby="new-email-help" + aria-invalid={!isEmailDifferent} + className={ + !isEmailDifferent + ? "border-red-500 focus-visible:ring-red-500" + : "" + } + /> + {!isEmailDifferent && newEmail && ( +

+ O novo e-mail deve ser + diferente do atual +

+ )} + {!newEmail && ( +

+ Digite o novo endereço de e-mail para sua conta +

+ )} +
- {/* Confirmar novo e-mail */} -
- -
- setConfirmEmail(e.target.value)} - disabled={isPending} - placeholder="Repita o novo e-mail" - required - aria-required="true" - aria-describedby="confirm-email-help" - aria-invalid={emailsMatch === false} - className={ - emailsMatch === false - ? "border-red-500 focus-visible:ring-red-500 pr-10" - : emailsMatch === true - ? "border-green-500 focus-visible:ring-green-500 pr-10" - : "" - } - /> - {/* Indicador visual de match */} - {emailsMatch !== null && ( -
- {emailsMatch ? ( - - ) : ( - - )} -
- )} -
- {/* Mensagem de erro em tempo real */} - {emailsMatch === false && ( - - )} - {emailsMatch === true && ( -

- - Os e-mails coincidem -

- )} -
-
+ {/* Confirmar novo e-mail */} +
+ +
+ setConfirmEmail(e.target.value)} + disabled={isPending} + placeholder="Repita o novo e-mail" + required + aria-required="true" + aria-describedby="confirm-email-help" + aria-invalid={emailsMatch === false} + className={ + emailsMatch === false + ? "border-red-500 focus-visible:ring-red-500 pr-10" + : emailsMatch === true + ? "border-green-500 focus-visible:ring-green-500 pr-10" + : "" + } + /> + {/* Indicador visual de match */} + {emailsMatch !== null && ( +
+ {emailsMatch ? ( + + ) : ( + + )} +
+ )} +
+ {/* Mensagem de erro em tempo real */} + {emailsMatch === false && ( + + )} + {emailsMatch === true && ( +

+ + Os e-mails coincidem +

+ )} +
+
-
- -
-
- ); +
+ +
+ + ); } diff --git a/components/ajustes/update-name-form.tsx b/components/ajustes/update-name-form.tsx index cf9dabe..0606d39 100644 --- a/components/ajustes/update-name-form.tsx +++ b/components/ajustes/update-name-form.tsx @@ -1,75 +1,75 @@ "use client"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { updateNameAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; type UpdateNameFormProps = { - currentName: string; + currentName: string; }; export function UpdateNameForm({ currentName }: UpdateNameFormProps) { - const [isPending, startTransition] = useTransition(); + const [isPending, startTransition] = useTransition(); - // Dividir o nome atual em primeiro nome e sobrenome - const nameParts = currentName.split(" "); - const initialFirstName = nameParts[0] || ""; - const initialLastName = nameParts.slice(1).join(" ") || ""; + // Dividir o nome atual em primeiro nome e sobrenome + const nameParts = currentName.split(" "); + const initialFirstName = nameParts[0] || ""; + const initialLastName = nameParts.slice(1).join(" ") || ""; - const [firstName, setFirstName] = useState(initialFirstName); - const [lastName, setLastName] = useState(initialLastName); + const [firstName, setFirstName] = useState(initialFirstName); + const [lastName, setLastName] = useState(initialLastName); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); - startTransition(async () => { - const result = await updateNameAction({ - firstName, - lastName, - }); + startTransition(async () => { + const result = await updateNameAction({ + firstName, + lastName, + }); - if (result.success) { - toast.success(result.message); - } else { - toast.error(result.error); - } - }); - }; + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.error); + } + }); + }; - return ( -
-
-
- - setFirstName(e.target.value)} - disabled={isPending} - required - /> -
+ return ( + +
+
+ + setFirstName(e.target.value)} + disabled={isPending} + required + /> +
-
- - setLastName(e.target.value)} - disabled={isPending} - required - /> -
-
+
+ + setLastName(e.target.value)} + disabled={isPending} + required + /> +
+
-
- -
-
- ); +
+ +
+ + ); } diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx index d4d3298..145c063 100644 --- a/components/ajustes/update-password-form.tsx +++ b/components/ajustes/update-password-form.tsx @@ -1,363 +1,365 @@ "use client"; +import { + RiAlertLine, + RiCheckLine, + RiCloseLine, + RiEyeLine, + RiEyeOffLine, +} from "@remixicon/react"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; import { updatePasswordAction } from "@/app/(dashboard)/ajustes/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils/ui"; -import { - RiEyeLine, - RiEyeOffLine, - RiCheckLine, - RiCloseLine, - RiAlertLine, -} from "@remixicon/react"; -import { useState, useTransition, useMemo } from "react"; -import { toast } from "sonner"; interface PasswordValidation { - hasLowercase: boolean; - hasUppercase: boolean; - hasNumber: boolean; - hasSpecial: boolean; - hasMinLength: boolean; - hasMaxLength: boolean; - isValid: boolean; + hasLowercase: boolean; + hasUppercase: boolean; + hasNumber: boolean; + hasSpecial: boolean; + hasMinLength: boolean; + hasMaxLength: boolean; + isValid: boolean; } function validatePassword(password: string): PasswordValidation { - const hasLowercase = /[a-z]/.test(password); - const hasUppercase = /[A-Z]/.test(password); - const hasNumber = /\d/.test(password); - const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]/.test(password); - const hasMinLength = password.length >= 7; - const hasMaxLength = password.length <= 23; + const hasLowercase = /[a-z]/.test(password); + const hasUppercase = /[A-Z]/.test(password); + const hasNumber = /\d/.test(password); + const hasSpecial = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password); + const hasMinLength = password.length >= 7; + const hasMaxLength = password.length <= 23; - return { - hasLowercase, - hasUppercase, - hasNumber, - hasSpecial, - hasMinLength, - hasMaxLength, - isValid: - hasLowercase && - hasUppercase && - hasNumber && - hasSpecial && - hasMinLength && - hasMaxLength, - }; + return { + hasLowercase, + hasUppercase, + hasNumber, + hasSpecial, + hasMinLength, + hasMaxLength, + isValid: + hasLowercase && + hasUppercase && + hasNumber && + hasSpecial && + hasMinLength && + hasMaxLength, + }; } function PasswordRequirement({ met, label }: { met: boolean; label: string }) { - return ( -
- {met ? ( - - ) : ( - - )} - {label} -
- ); + return ( +
+ {met ? ( + + ) : ( + + )} + {label} +
+ ); } type UpdatePasswordFormProps = { - authProvider?: string; // 'google' | 'credential' | undefined + authProvider?: string; // 'google' | 'credential' | undefined }; export function UpdatePasswordForm({ authProvider }: UpdatePasswordFormProps) { - const [isPending, startTransition] = useTransition(); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [showCurrentPassword, setShowCurrentPassword] = useState(false); - const [showNewPassword, setShowNewPassword] = useState(false); - const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isPending, startTransition] = useTransition(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); - // Verificar se o usuário usa login via Google - const isGoogleAuth = authProvider === "google"; + // Verificar se o usuário usa login via Google + const isGoogleAuth = authProvider === "google"; - // Validação em tempo real: senhas coincidem - const passwordsMatch = useMemo(() => { - if (!confirmPassword) return null; // Não mostrar erro se campo vazio - return newPassword === confirmPassword; - }, [newPassword, confirmPassword]); + // Validação em tempo real: senhas coincidem + const passwordsMatch = useMemo(() => { + if (!confirmPassword) return null; // Não mostrar erro se campo vazio + return newPassword === confirmPassword; + }, [newPassword, confirmPassword]); - // Validação de requisitos da senha - const passwordValidation = useMemo( - () => validatePassword(newPassword), - [newPassword] - ); + // Validação de requisitos da senha + const passwordValidation = useMemo( + () => validatePassword(newPassword), + [newPassword], + ); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); - // Validação frontend antes de enviar - if (!passwordValidation.isValid) { - toast.error("A senha não atende aos requisitos de segurança"); - return; - } + // Validação frontend antes de enviar + if (!passwordValidation.isValid) { + toast.error("A senha não atende aos requisitos de segurança"); + return; + } - if (newPassword !== confirmPassword) { - toast.error("As senhas não coincidem"); - return; - } + if (newPassword !== confirmPassword) { + toast.error("As senhas não coincidem"); + return; + } - startTransition(async () => { - const result = await updatePasswordAction({ - currentPassword, - newPassword, - confirmPassword, - }); + startTransition(async () => { + const result = await updatePasswordAction({ + currentPassword, + newPassword, + confirmPassword, + }); - if (result.success) { - toast.success(result.message); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - } else { - toast.error(result.error); - } - }); - }; + if (result.success) { + toast.success(result.message); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } else { + toast.error(result.error); + } + }); + }; - // Se o usuário usa Google OAuth, mostrar aviso - if (isGoogleAuth) { - return ( -
-
- -
-

- Alteração de senha não disponível -

-

- Você fez login usando sua conta do Google. A senha é gerenciada - diretamente pelo Google e não pode ser alterada aqui. Para - modificar sua senha, acesse as configurações de segurança da sua - conta Google. -

-
-
-
- ); - } + // Se o usuário usa Google OAuth, mostrar aviso + if (isGoogleAuth) { + return ( +
+
+ +
+

+ Alteração de senha não disponível +

+

+ Você fez login usando sua conta do Google. A senha é gerenciada + diretamente pelo Google e não pode ser alterada aqui. Para + modificar sua senha, acesse as configurações de segurança da sua + conta Google. +

+
+
+
+ ); + } - return ( -
-
- {/* Senha atual */} -
- -
- setCurrentPassword(e.target.value)} - disabled={isPending} - placeholder="Digite sua senha atual" - required - aria-required="true" - aria-describedby="current-password-help" - /> - -
-

- Por segurança, confirme sua senha atual antes de alterá-la -

-
+ return ( + +
+ {/* Senha atual */} +
+ +
+ setCurrentPassword(e.target.value)} + disabled={isPending} + placeholder="Digite sua senha atual" + required + aria-required="true" + aria-describedby="current-password-help" + /> + +
+

+ Por segurança, confirme sua senha atual antes de alterá-la +

+
- {/* Nova senha */} -
- -
- setNewPassword(e.target.value)} - disabled={isPending} - placeholder="Crie uma senha forte" - required - minLength={7} - maxLength={23} - aria-required="true" - aria-describedby="new-password-help" - aria-invalid={ - newPassword.length > 0 && !passwordValidation.isValid - } - /> - -
- {/* Indicadores de requisitos da senha */} - {newPassword.length > 0 && ( -
- - - - - - -
- )} -
+ {/* Nova senha */} +
+ +
+ setNewPassword(e.target.value)} + disabled={isPending} + placeholder="Crie uma senha forte" + required + minLength={7} + maxLength={23} + aria-required="true" + aria-describedby="new-password-help" + aria-invalid={ + newPassword.length > 0 && !passwordValidation.isValid + } + /> + +
+ {/* Indicadores de requisitos da senha */} + {newPassword.length > 0 && ( +
+ + + + + + +
+ )} +
- {/* Confirmar nova senha */} -
- -
- setConfirmPassword(e.target.value)} - disabled={isPending} - placeholder="Repita a senha" - required - minLength={6} - aria-required="true" - aria-describedby="confirm-password-help" - aria-invalid={passwordsMatch === false} - className={ - passwordsMatch === false - ? "border-red-500 focus-visible:ring-red-500" - : passwordsMatch === true - ? "border-green-500 focus-visible:ring-green-500" - : "" - } - /> - - {/* Indicador visual de match */} - {passwordsMatch !== null && ( -
- {passwordsMatch ? ( - - ) : ( - - )} -
- )} -
- {/* Mensagem de erro em tempo real */} - {passwordsMatch === false && ( - - )} - {passwordsMatch === true && ( -

- - As senhas coincidem -

- )} -
-
+ {/* Confirmar nova senha */} +
+ +
+ setConfirmPassword(e.target.value)} + disabled={isPending} + placeholder="Repita a senha" + required + minLength={6} + aria-required="true" + aria-describedby="confirm-password-help" + aria-invalid={passwordsMatch === false} + className={ + passwordsMatch === false + ? "border-red-500 focus-visible:ring-red-500" + : passwordsMatch === true + ? "border-green-500 focus-visible:ring-green-500" + : "" + } + /> + + {/* Indicador visual de match */} + {passwordsMatch !== null && ( +
+ {passwordsMatch ? ( + + ) : ( + + )} +
+ )} +
+ {/* Mensagem de erro em tempo real */} + {passwordsMatch === false && ( + + )} + {passwordsMatch === true && ( +

+ + As senhas coincidem +

+ )} +
+
-
- -
-
- ); +
+ +
+ + ); } diff --git a/components/animated-theme-toggler.tsx b/components/animated-theme-toggler.tsx index 39a6da2..f97cac8 100644 --- a/components/animated-theme-toggler.tsx +++ b/components/animated-theme-toggler.tsx @@ -1,122 +1,122 @@ "use client"; +import { RiMoonClearLine, RiSunLine } from "@remixicon/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { buttonVariants } from "@/components/ui/button"; import { - Tooltip, - TooltipContent, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils/ui"; -import { RiMoonClearLine, RiSunLine } from "@remixicon/react"; interface AnimatedThemeTogglerProps - extends React.ComponentPropsWithoutRef<"button"> { - duration?: number; + extends React.ComponentPropsWithoutRef<"button"> { + duration?: number; } export const AnimatedThemeToggler = ({ - className, - duration = 400, - ...props + className, + duration = 400, + ...props }: AnimatedThemeTogglerProps) => { - const [isDark, setIsDark] = useState(false); - const buttonRef = useRef(null); + const [isDark, setIsDark] = useState(false); + const buttonRef = useRef(null); - useEffect(() => { - const updateTheme = () => { - setIsDark(document.documentElement.classList.contains("dark")); - }; + useEffect(() => { + const updateTheme = () => { + setIsDark(document.documentElement.classList.contains("dark")); + }; - updateTheme(); + updateTheme(); - const observer = new MutationObserver(updateTheme); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); + const observer = new MutationObserver(updateTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); - return () => observer.disconnect(); - }, []); + return () => observer.disconnect(); + }, []); - const toggleTheme = useCallback(async () => { - if (!buttonRef.current) return; + const toggleTheme = useCallback(async () => { + if (!buttonRef.current) return; - await document.startViewTransition(() => { - flushSync(() => { - const newTheme = !isDark; - setIsDark(newTheme); - document.documentElement.classList.toggle("dark"); - localStorage.setItem("theme", newTheme ? "dark" : "light"); - }); - }).ready; + await document.startViewTransition(() => { + flushSync(() => { + const newTheme = !isDark; + setIsDark(newTheme); + document.documentElement.classList.toggle("dark"); + localStorage.setItem("theme", newTheme ? "dark" : "light"); + }); + }).ready; - const { top, left, width, height } = - buttonRef.current.getBoundingClientRect(); - const x = left + width / 2; - const y = top + height / 2; - const maxRadius = Math.hypot( - Math.max(left, window.innerWidth - left), - Math.max(top, window.innerHeight - top) - ); + const { top, left, width, height } = + buttonRef.current.getBoundingClientRect(); + const x = left + width / 2; + const y = top + height / 2; + const maxRadius = Math.hypot( + Math.max(left, window.innerWidth - left), + Math.max(top, window.innerHeight - top), + ); - document.documentElement.animate( - { - clipPath: [ - `circle(0px at ${x}px ${y}px)`, - `circle(${maxRadius}px at ${x}px ${y}px)`, - ], - }, - { - duration, - easing: "ease-in-out", - pseudoElement: "::view-transition-new(root)", - } - ); - }, [isDark, duration]); + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${maxRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + }, + ); + }, [isDark, duration]); - return ( - - - - - - {isDark ? "Tema claro" : "Tema escuro"} - - - ); + return ( + + + + + + {isDark ? "Tema claro" : "Tema escuro"} + + + ); }; diff --git a/components/anotacoes/note-card.tsx b/components/anotacoes/note-card.tsx index 94dc85e..6b2e7ea 100644 --- a/components/anotacoes/note-card.tsx +++ b/components/anotacoes/note-card.tsx @@ -1,158 +1,158 @@ "use client"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { - RiArchiveLine, - RiCheckLine, - RiDeleteBin5Line, - RiEyeLine, - RiInboxUnarchiveLine, - RiPencilLine, + RiArchiveLine, + RiCheckLine, + RiDeleteBin5Line, + RiEyeLine, + RiInboxUnarchiveLine, + RiPencilLine, } from "@remixicon/react"; import { useMemo } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; import type { Note } from "./types"; const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - dateStyle: "medium", + dateStyle: "medium", }); interface NoteCardProps { - note: Note; - onEdit?: (note: Note) => void; - onDetails?: (note: Note) => void; - onRemove?: (note: Note) => void; - onArquivar?: (note: Note) => void; - isArquivadas?: boolean; + note: Note; + onEdit?: (note: Note) => void; + onDetails?: (note: Note) => void; + onRemove?: (note: Note) => void; + onArquivar?: (note: Note) => void; + isArquivadas?: boolean; } export function NoteCard({ - note, - onEdit, - onDetails, - onRemove, - onArquivar, - isArquivadas = false, + note, + onEdit, + onDetails, + onRemove, + onArquivar, + isArquivadas = false, }: NoteCardProps) { - const { formattedDate, displayTitle } = useMemo(() => { - const resolvedTitle = note.title.trim().length - ? note.title - : "Anotação sem título"; + const { formattedDate, displayTitle } = useMemo(() => { + const resolvedTitle = note.title.trim().length + ? note.title + : "Anotação sem título"; - return { - displayTitle: resolvedTitle, - formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), - }; - }, [note.createdAt, note.title]); + return { + displayTitle: resolvedTitle, + formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), + }; + }, [note.createdAt, note.title]); - const isTask = note.type === "tarefa"; - const tasks = note.tasks || []; - const completedCount = tasks.filter((t) => t.completed).length; - const totalCount = tasks.length; + const isTask = note.type === "tarefa"; + const tasks = note.tasks || []; + const completedCount = tasks.filter((t) => t.completed).length; + const totalCount = tasks.length; - const actions = [ - { - label: "editar", - icon: , - onClick: onEdit, - variant: "default" as const, - }, - { - label: "detalhes", - icon: , - onClick: onDetails, - variant: "default" as const, - }, - { - label: isArquivadas ? "desarquivar" : "arquivar", - icon: isArquivadas ? ( - - ) : ( - - ), - onClick: onArquivar, - variant: "default" as const, - }, - { - label: "remover", - icon: , - onClick: onRemove, - variant: "destructive" as const, - }, - ].filter((action) => typeof action.onClick === "function"); + const actions = [ + { + label: "editar", + icon: , + onClick: onEdit, + variant: "default" as const, + }, + { + label: "detalhes", + icon: , + onClick: onDetails, + variant: "default" as const, + }, + { + label: isArquivadas ? "desarquivar" : "arquivar", + icon: isArquivadas ? ( + + ) : ( + + ), + onClick: onArquivar, + variant: "default" as const, + }, + { + label: "remover", + icon: , + onClick: onRemove, + variant: "destructive" as const, + }, + ].filter((action) => typeof action.onClick === "function"); - return ( - - -
-
-

- {displayTitle} -

-
- {isTask && ( - - {completedCount}/{totalCount} concluídas - - )} -
+ return ( + + +
+
+

+ {displayTitle} +

+
+ {isTask && ( + + {completedCount}/{totalCount} concluídas + + )} +
- {isTask ? ( -
- {tasks.slice(0, 5).map((task) => ( -
-
- {task.completed && ( - - )} -
- - {task.text} - -
- ))} - {tasks.length > 5 && ( -

- +{tasks.length - 5} - {tasks.length - 5 === 1 ? "tarefa" : "tarefas"}... -

- )} -
- ) : ( -

- {note.description} -

- )} -
+ {isTask ? ( +
+ {tasks.slice(0, 5).map((task) => ( +
+
+ {task.completed && ( + + )} +
+ + {task.text} + +
+ ))} + {tasks.length > 5 && ( +

+ +{tasks.length - 5} + {tasks.length - 5 === 1 ? "tarefa" : "tarefas"}... +

+ )} +
+ ) : ( +

+ {note.description} +

+ )} +
- {actions.length > 0 ? ( - - {actions.map(({ label, icon, onClick, variant }) => ( - - ))} - - ) : null} -
- ); + {actions.length > 0 ? ( + + {actions.map(({ label, icon, onClick, variant }) => ( + + ))} + + ) : null} + + ); } diff --git a/components/anotacoes/note-details-dialog.tsx b/components/anotacoes/note-details-dialog.tsx index 45addf4..51d49e0 100644 --- a/components/anotacoes/note-details-dialog.tsx +++ b/components/anotacoes/note-details-dialog.tsx @@ -1,116 +1,116 @@ "use client"; +import { RiCheckLine } from "@remixicon/react"; +import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@/components/ui/dialog"; -import { RiCheckLine } from "@remixicon/react"; -import { useMemo } from "react"; import { Card } from "../ui/card"; import type { Note } from "./types"; const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", { - dateStyle: "long", - timeStyle: "short", + dateStyle: "long", + timeStyle: "short", }); interface NoteDetailsDialogProps { - note: Note | null; - open: boolean; - onOpenChange: (open: boolean) => void; + note: Note | null; + open: boolean; + onOpenChange: (open: boolean) => void; } export function NoteDetailsDialog({ - note, - open, - onOpenChange, + note, + open, + onOpenChange, }: NoteDetailsDialogProps) { - const { formattedDate, displayTitle } = useMemo(() => { - if (!note) { - return { formattedDate: "", displayTitle: "" }; - } + const { formattedDate, displayTitle } = useMemo(() => { + if (!note) { + return { formattedDate: "", displayTitle: "" }; + } - const title = note.title.trim().length ? note.title : "Anotação sem título"; + const title = note.title.trim().length ? note.title : "Anotação sem título"; - return { - formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), - displayTitle: title, - }; - }, [note]); + return { + formattedDate: DATE_FORMATTER.format(new Date(note.createdAt)), + displayTitle: title, + }; + }, [note]); - if (!note) { - return null; - } + if (!note) { + return null; + } - const isTask = note.type === "tarefa"; - const tasks = note.tasks || []; - const completedCount = tasks.filter((t) => t.completed).length; - const totalCount = tasks.length; + const isTask = note.type === "tarefa"; + const tasks = note.tasks || []; + const completedCount = tasks.filter((t) => t.completed).length; + const totalCount = tasks.length; - return ( - - - - - {displayTitle} - {isTask && ( - - {completedCount}/{totalCount} - - )} - - {formattedDate} - + return ( + + + + + {displayTitle} + {isTask && ( + + {completedCount}/{totalCount} + + )} + + {formattedDate} + - {isTask ? ( -
- {tasks.map((task) => ( - -
- {task.completed && ( - - )} -
- - {task.text} - -
- ))} -
- ) : ( -
- {note.description} -
- )} + {isTask ? ( +
+ {tasks.map((task) => ( + +
+ {task.completed && ( + + )} +
+ + {task.text} + +
+ ))} +
+ ) : ( +
+ {note.description} +
+ )} - - - - - -
-
- ); + + + + + +
+
+ ); } diff --git a/components/anotacoes/note-dialog.tsx b/components/anotacoes/note-dialog.tsx index c3b2900..4e201af 100644 --- a/components/anotacoes/note-dialog.tsx +++ b/components/anotacoes/note-dialog.tsx @@ -1,45 +1,45 @@ "use client"; +import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"; import { - createNoteAction, - updateNoteAction, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, + useTransition, +} from "react"; +import { toast } from "sonner"; +import { + createNoteAction, + updateNoteAction, } from "@/app/(dashboard)/anotacoes/actions"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Textarea } from "@/components/ui/textarea"; import { useControlledState } from "@/hooks/use-controlled-state"; import { useFormState } from "@/hooks/use-form-state"; -import { RiAddLine, RiDeleteBinLine } from "@remixicon/react"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, - useTransition, -} from "react"; -import { toast } from "sonner"; import { Card } from "../ui/card"; import type { Note, NoteFormValues, Task } from "./types"; type NoteDialogMode = "create" | "update"; interface NoteDialogProps { - mode: NoteDialogMode; - trigger?: ReactNode; - note?: Note; - open?: boolean; - onOpenChange?: (open: boolean) => void; + mode: NoteDialogMode; + trigger?: ReactNode; + note?: Note; + open?: boolean; + onOpenChange?: (open: boolean) => void; } const MAX_TITLE = 30; @@ -47,426 +47,426 @@ const MAX_DESC = 350; const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); const buildInitialValues = (note?: Note): NoteFormValues => ({ - title: note?.title ?? "", - description: note?.description ?? "", - type: note?.type ?? "nota", - tasks: note?.tasks ?? [], + title: note?.title ?? "", + description: note?.description ?? "", + type: note?.type ?? "nota", + tasks: note?.tasks ?? [], }); const generateTaskId = () => { - return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; }; export function NoteDialog({ - mode, - trigger, - note, - open, - onOpenChange, + mode, + trigger, + note, + open, + onOpenChange, }: NoteDialogProps) { - const [isPending, startTransition] = useTransition(); - const [errorMessage, setErrorMessage] = useState(null); - const [newTaskText, setNewTaskText] = useState(""); + const [isPending, startTransition] = useTransition(); + const [errorMessage, setErrorMessage] = useState(null); + const [newTaskText, setNewTaskText] = useState(""); - const titleRef = useRef(null); - const descRef = useRef(null); - const newTaskRef = useRef(null); + const titleRef = useRef(null); + const descRef = useRef(null); + const newTaskRef = useRef(null); - // Use controlled state hook for dialog open state - const [dialogOpen, setDialogOpen] = useControlledState( - open, - false, - onOpenChange - ); + // Use controlled state hook for dialog open state + const [dialogOpen, setDialogOpen] = useControlledState( + open, + false, + onOpenChange, + ); - const initialState = buildInitialValues(note); + const initialState = buildInitialValues(note); - // Use form state hook for form management - const { formState, updateField, setFormState } = - useFormState(initialState); + // Use form state hook for form management + const { formState, updateField, setFormState } = + useFormState(initialState); - useEffect(() => { - if (dialogOpen) { - setFormState(buildInitialValues(note)); - setErrorMessage(null); - setNewTaskText(""); - requestAnimationFrame(() => titleRef.current?.focus()); - } - }, [dialogOpen, note, setFormState]); + useEffect(() => { + if (dialogOpen) { + setFormState(buildInitialValues(note)); + setErrorMessage(null); + setNewTaskText(""); + requestAnimationFrame(() => titleRef.current?.focus()); + } + }, [dialogOpen, note, setFormState]); - const title = mode === "create" ? "Nova anotação" : "Editar anotação"; - const description = - mode === "create" - ? "Escolha entre uma nota simples ou uma lista de tarefas." - : "Altere o título e/ou conteúdo desta anotação."; - const submitLabel = - mode === "create" ? "Salvar anotação" : "Atualizar anotação"; + const title = mode === "create" ? "Nova anotação" : "Editar anotação"; + const description = + mode === "create" + ? "Escolha entre uma nota simples ou uma lista de tarefas." + : "Altere o título e/ou conteúdo desta anotação."; + const submitLabel = + mode === "create" ? "Salvar anotação" : "Atualizar anotação"; - const titleCount = formState.title.length; - const descCount = formState.description.length; - const isNote = formState.type === "nota"; + const titleCount = formState.title.length; + const descCount = formState.description.length; + const isNote = formState.type === "nota"; - const onlySpaces = - normalize(formState.title).length === 0 || - (isNote && normalize(formState.description).length === 0) || - (!isNote && (!formState.tasks || formState.tasks.length === 0)); + const onlySpaces = + normalize(formState.title).length === 0 || + (isNote && normalize(formState.description).length === 0) || + (!isNote && (!formState.tasks || formState.tasks.length === 0)); - const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC; + const invalidLen = titleCount > MAX_TITLE || descCount > MAX_DESC; - const unchanged = - mode === "update" && - normalize(formState.title) === normalize(note?.title ?? "") && - normalize(formState.description) === normalize(note?.description ?? "") && - JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks); + const unchanged = + mode === "update" && + normalize(formState.title) === normalize(note?.title ?? "") && + normalize(formState.description) === normalize(note?.description ?? "") && + JSON.stringify(formState.tasks) === JSON.stringify(note?.tasks); - const disableSubmit = isPending || onlySpaces || unchanged || invalidLen; + const disableSubmit = isPending || onlySpaces || unchanged || invalidLen; - const handleOpenChange = useCallback( - (v: boolean) => { - setDialogOpen(v); - if (!v) setErrorMessage(null); - }, - [setDialogOpen] - ); + const handleOpenChange = useCallback( + (v: boolean) => { + setDialogOpen(v); + if (!v) setErrorMessage(null); + }, + [setDialogOpen], + ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "Enter") - (e.currentTarget as HTMLFormElement).requestSubmit(); - if (e.key === "Escape") handleOpenChange(false); - }, - [handleOpenChange] - ); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") + (e.currentTarget as HTMLFormElement).requestSubmit(); + if (e.key === "Escape") handleOpenChange(false); + }, + [handleOpenChange], + ); - const handleAddTask = useCallback(() => { - const text = normalize(newTaskText); - if (!text) return; + const handleAddTask = useCallback(() => { + const text = normalize(newTaskText); + if (!text) return; - const newTask: Task = { - id: generateTaskId(), - text, - completed: false, - }; + const newTask: Task = { + id: generateTaskId(), + text, + completed: false, + }; - updateField("tasks", [...(formState.tasks || []), newTask]); - setNewTaskText(""); - requestAnimationFrame(() => newTaskRef.current?.focus()); - }, [newTaskText, formState.tasks, updateField]); + updateField("tasks", [...(formState.tasks || []), newTask]); + setNewTaskText(""); + requestAnimationFrame(() => newTaskRef.current?.focus()); + }, [newTaskText, formState.tasks, updateField]); - const handleRemoveTask = useCallback( - (taskId: string) => { - updateField( - "tasks", - (formState.tasks || []).filter((t) => t.id !== taskId) - ); - }, - [formState.tasks, updateField] - ); + const handleRemoveTask = useCallback( + (taskId: string) => { + updateField( + "tasks", + (formState.tasks || []).filter((t) => t.id !== taskId), + ); + }, + [formState.tasks, updateField], + ); - const handleToggleTask = useCallback( - (taskId: string) => { - updateField( - "tasks", - (formState.tasks || []).map((t) => - t.id === taskId ? { ...t, completed: !t.completed } : t - ) - ); - }, - [formState.tasks, updateField] - ); + const handleToggleTask = useCallback( + (taskId: string) => { + updateField( + "tasks", + (formState.tasks || []).map((t) => + t.id === taskId ? { ...t, completed: !t.completed } : t, + ), + ); + }, + [formState.tasks, updateField], + ); - const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - setErrorMessage(null); + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + setErrorMessage(null); - const payload = { - title: normalize(formState.title), - description: normalize(formState.description), - type: formState.type, - tasks: formState.tasks, - }; + const payload = { + title: normalize(formState.title), + description: normalize(formState.description), + type: formState.type, + tasks: formState.tasks, + }; - if (onlySpaces || invalidLen) { - setErrorMessage("Preencha os campos respeitando os limites."); - titleRef.current?.focus(); - return; - } + if (onlySpaces || invalidLen) { + setErrorMessage("Preencha os campos respeitando os limites."); + titleRef.current?.focus(); + return; + } - if (mode === "update" && !note?.id) { - const msg = "Não foi possível identificar a anotação a ser editada."; - setErrorMessage(msg); - toast.error(msg); - return; - } + if (mode === "update" && !note?.id) { + const msg = "Não foi possível identificar a anotação a ser editada."; + setErrorMessage(msg); + toast.error(msg); + return; + } - if (unchanged) { - toast.info("Nada para atualizar."); - return; - } + if (unchanged) { + toast.info("Nada para atualizar."); + return; + } - startTransition(async () => { - let result; - if (mode === "create") { - result = await createNoteAction(payload); - } else { - if (!note?.id) { - const msg = "ID da anotação não encontrado."; - setErrorMessage(msg); - toast.error(msg); - return; - } - result = await updateNoteAction({ id: note.id, ...payload }); - } + startTransition(async () => { + let result; + if (mode === "create") { + result = await createNoteAction(payload); + } else { + if (!note?.id) { + const msg = "ID da anotação não encontrado."; + setErrorMessage(msg); + toast.error(msg); + return; + } + result = await updateNoteAction({ id: note.id, ...payload }); + } - if (result.success) { - toast.success(result.message); - setDialogOpen(false); - return; - } - setErrorMessage(result.error); - toast.error(result.error); - titleRef.current?.focus(); - }); - }, - [ - formState.title, - formState.description, - formState.type, - formState.tasks, - mode, - note, - setDialogOpen, - onlySpaces, - unchanged, - invalidLen, - ] - ); + if (result.success) { + toast.success(result.message); + setDialogOpen(false); + return; + } + setErrorMessage(result.error); + toast.error(result.error); + titleRef.current?.focus(); + }); + }, + [ + formState.title, + formState.description, + formState.type, + formState.tasks, + mode, + note, + setDialogOpen, + onlySpaces, + unchanged, + invalidLen, + ], + ); - return ( - - {trigger ? {trigger} : null} - - - {title} - {description} - + return ( + + {trigger ? {trigger} : null} + + + {title} + {description} + -
- {/* Seletor de Tipo - apenas no modo de criação */} - {mode === "create" && ( -
- - - updateField("type", value as "nota" | "tarefa") - } - disabled={isPending} - className="flex gap-4" - > -
- - -
-
- - -
-
-
- )} + + {/* Seletor de Tipo - apenas no modo de criação */} + {mode === "create" && ( +
+ + + updateField("type", value as "nota" | "tarefa") + } + disabled={isPending} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ )} - {/* Título */} -
- - updateField("title", e.target.value)} - placeholder={ - isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana" - } - maxLength={MAX_TITLE} - disabled={isPending} - aria-describedby="note-title-help" - required - /> -

- Até {MAX_TITLE} caracteres. Restantes:{" "} - {Math.max(0, MAX_TITLE - titleCount)}. -

-
+ {/* Título */} +
+ + updateField("title", e.target.value)} + placeholder={ + isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana" + } + maxLength={MAX_TITLE} + disabled={isPending} + aria-describedby="note-title-help" + required + /> +

+ Até {MAX_TITLE} caracteres. Restantes:{" "} + {Math.max(0, MAX_TITLE - titleCount)}. +

+
- {/* Conteúdo - apenas para Notas */} - {isNote && ( -
- -