feat: adição de novos ícones SVG e configuração do ambiente
- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter - Implementados ícones para modos claro e escuro do ChatGPT - Criado script de inicialização para PostgreSQL com extensão pgcrypto - Adicionado script de configuração de ambiente que faz backup do .env - Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
61
.dockerignore
Normal file
61
.dockerignore
Normal file
@@ -0,0 +1,61 @@
|
||||
# Dependências
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store
|
||||
|
||||
# Build do Next.js
|
||||
.next
|
||||
out
|
||||
dist
|
||||
|
||||
# Arquivos de ambiente (serão passados via docker-compose)
|
||||
.env
|
||||
.env*.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Testes
|
||||
coverage
|
||||
.nyc_output
|
||||
*.test.ts
|
||||
*.test.tsx
|
||||
*.spec.ts
|
||||
*.spec.tsx
|
||||
__tests__
|
||||
__mocks__
|
||||
|
||||
# IDE e editores
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
README.md
|
||||
LICENSE
|
||||
.eslintcache
|
||||
.prettierignore
|
||||
.editorconfig
|
||||
48
.env.example
Normal file
48
.env.example
Normal file
@@ -0,0 +1,48 @@
|
||||
# ============================================
|
||||
# OPENSHEETS - Variáveis de Ambiente
|
||||
# ============================================
|
||||
#
|
||||
# Setup: cp .env.example .env
|
||||
# Docs: README.md
|
||||
#
|
||||
# ============================================
|
||||
|
||||
# === Database ===
|
||||
# PostgreSQL local (Docker): use host "db"
|
||||
# PostgreSQL local (sem Docker): use host "localhost"
|
||||
# PostgreSQL remoto: use URL completa do provider
|
||||
DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db
|
||||
|
||||
# Credenciais do PostgreSQL (apenas para Docker local) - Alterar
|
||||
POSTGRES_USER=opensheets
|
||||
POSTGRES_PASSWORD=opensheets_dev_password
|
||||
POSTGRES_DB=opensheets_db
|
||||
|
||||
# Provider: "local" para Docker, "remote" para Supabase/Neon/etc
|
||||
DB_PROVIDER=local
|
||||
|
||||
# === Better Auth ===
|
||||
# Gere com: openssl rand -base64 32
|
||||
BETTER_AUTH_SECRET=your-secret-key-here-change-this
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# === Portas ===
|
||||
APP_PORT=3000
|
||||
DB_PORT=5432
|
||||
|
||||
# === Email (Opcional) ===
|
||||
# Provider: Resend (https://resend.com)
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# === OAuth (Opcional) ===
|
||||
# Google: https://console.cloud.google.com/apis/credentials
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
NEXT_PUBLIC_GOOGLE_OAUTH_ENABLED=true
|
||||
|
||||
# === AI Providers (Opcional) ===
|
||||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
133
.gitignore
vendored
Normal file
133
.gitignore
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# ============================================
|
||||
# OPENSHEETS - .gitignore
|
||||
# ============================================
|
||||
|
||||
# === Dependencies ===
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# === Next.js ===
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
.turbo
|
||||
|
||||
# === Build ===
|
||||
/build
|
||||
/dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# === Testing ===
|
||||
/coverage
|
||||
*.lcov
|
||||
|
||||
# === Environment Variables ===
|
||||
# Ignora todos os .env exceto .env.example
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# === Logs ===
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# === OS Files ===
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# === IDEs ===
|
||||
# VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# JetBrains (WebStorm, IntelliJ, etc)
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Vim
|
||||
[._]*.s[a-v][a-z]
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# === Certificates ===
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.crt
|
||||
|
||||
# === Deploy Platforms ===
|
||||
.vercel
|
||||
.netlify
|
||||
|
||||
# === Database ===
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# === Docker ===
|
||||
# Não ignora docker-compose.yml e Dockerfile
|
||||
# Ignora apenas dados e logs locais
|
||||
docker-compose.override.yml
|
||||
*.log
|
||||
|
||||
# === AI Assistants (Claude, Gemini, Cursor, etc) ===
|
||||
# Arquivos de configuração de assistentes de IA
|
||||
.claude/
|
||||
.gemini/
|
||||
.cursor/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
claude.md
|
||||
agents.md
|
||||
|
||||
# === Backups e Temporários ===
|
||||
*.bak
|
||||
*.backup
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
|
||||
# === Outros ===
|
||||
# Arquivos de lock temporários
|
||||
package-lock.json # Se usa pnpm, não precisa do npm lock
|
||||
yarn.lock # Se usa pnpm, não precisa do yarn lock
|
||||
|
||||
# Drizzle Studio local cache
|
||||
.drizzle/
|
||||
|
||||
# TypeScript cache
|
||||
.tsbuildinfo
|
||||
|
||||
# Local development files
|
||||
.local/
|
||||
local/
|
||||
scratch/
|
||||
playground/
|
||||
17
.vscode/settings.json
vendored
Normal file
17
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
96
Dockerfile
Normal file
96
Dockerfile
Normal file
@@ -0,0 +1,96 @@
|
||||
# Dockerfile para Next.js 16 com multi-stage build otimizado
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Instalação de dependências
|
||||
# ============================================
|
||||
FROM node:22-alpine AS deps
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar apenas arquivos de dependências para aproveitar cache
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Instalar dependências (production + dev para o build)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Build da aplicação
|
||||
# ============================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar dependências instaladas do stage anterior
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copiar todo o código fonte
|
||||
COPY . .
|
||||
|
||||
# Variáveis de ambiente necessárias para o build
|
||||
# DATABASE_URL será fornecida em runtime, mas precisa estar definida para validação
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_ENV=production
|
||||
|
||||
# Build da aplicação Next.js
|
||||
# Nota: Se houver erros de tipo, ajuste typescript.ignoreBuildErrors no next.config.ts
|
||||
RUN pnpm build
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Runtime (produção)
|
||||
# ============================================
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
# Instalar pnpm globalmente
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Criar usuário não-root para segurança
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copiar apenas arquivos necessários para produção
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
|
||||
# Copiar arquivos de build do Next.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Copiar arquivos do Drizzle (migrations e schema)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/db ./db
|
||||
|
||||
# Copiar node_modules para ter drizzle-kit disponível para migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Definir variáveis de ambiente de produção
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
# Expor porta
|
||||
EXPOSE 3000
|
||||
|
||||
# Ajustar permissões para o usuário nextjs
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
# Mudar para usuário não-root
|
||||
USER nextjs
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" || exit 1
|
||||
|
||||
# Comando de inicialização
|
||||
# Nota: Em produção com standalone build, o servidor é iniciado pelo arquivo server.js
|
||||
CMD ["node", "server.js"]
|
||||
11
app/(auth)/login/page.tsx
Normal file
11
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
app/(auth)/signup/page.tsx
Normal file
11
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SignupForm } from "@/components/auth/signup-form";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm md:max-w-4xl">
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
app/(dashboard)/ajustes/actions.ts
Normal file
257
app/(dashboard)/ajustes/actions.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { db, schema } from "@/lib/db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
type ActionResponse<T = void> = {
|
||||
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"),
|
||||
});
|
||||
|
||||
const updatePasswordSchema = z
|
||||
.object({
|
||||
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({
|
||||
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' }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Actions
|
||||
|
||||
export async function updateNameAction(
|
||||
data: z.infer<typeof updateNameSchema>
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não autenticado",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = updateNameSchema.parse(data);
|
||||
const fullName = `${validated.firstName} ${validated.lastName}`;
|
||||
|
||||
await db
|
||||
.update(schema.user)
|
||||
.set({ name: fullName })
|
||||
.where(eq(schema.user.id, session.user.id));
|
||||
|
||||
// Revalidar o layout do dashboard para atualizar a sidebar
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Nome atualizado com sucesso",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.errors[0]?.message || "Dados inválidos",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Erro ao atualizar nome:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao atualizar nome. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordAction(
|
||||
data: z.infer<typeof updatePasswordSchema>
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user?.id || !session?.user?.email) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não autenticado",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = updatePasswordSchema.parse(data);
|
||||
|
||||
// Usar a API do Better Auth para atualizar a senha
|
||||
try {
|
||||
await auth.api.changePassword({
|
||||
body: {
|
||||
newPassword: validated.newPassword,
|
||||
currentPassword: "", // Better Auth pode não exigir a senha atual dependendo da configuração
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Senha atualizada com sucesso",
|
||||
};
|
||||
} catch (authError) {
|
||||
console.error("Erro na API do Better Auth:", authError);
|
||||
// Se a API do Better Auth falhar, retornar erro genérico
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao atualizar senha. Tente novamente.",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.errors[0]?.message || "Dados inválidos",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Erro ao atualizar senha:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao atualizar senha. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEmailAction(
|
||||
data: z.infer<typeof updateEmailSchema>
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não autenticado",
|
||||
};
|
||||
}
|
||||
|
||||
const validated = updateEmailSchema.parse(data);
|
||||
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
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.errors[0]?.message || "Dados inválidos",
|
||||
};
|
||||
}
|
||||
|
||||
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<typeof deleteAccountSchema>
|
||||
): Promise<ActionResponse> {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Não autenticado",
|
||||
};
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta deletada com sucesso",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.errors[0]?.message || "Dados inválidos",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Erro ao deletar conta:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Erro ao deletar conta. Tente novamente.",
|
||||
};
|
||||
}
|
||||
}
|
||||
23
app/(dashboard)/ajustes/layout.tsx
Normal file
23
app/(dashboard)/ajustes/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSettingsLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Ajustes | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiSettingsLine />}
|
||||
title="Ajustes"
|
||||
subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
app/(dashboard)/ajustes/page.tsx
Normal file
85
app/(dashboard)/ajustes/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const userName = session.user.name || "";
|
||||
const userEmail = session.user.email || "";
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<Tabs defaultValue="nome" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-4 mb-2">
|
||||
<TabsTrigger value="nome">Altere seu nome</TabsTrigger>
|
||||
<TabsTrigger value="senha">Alterar senha</TabsTrigger>
|
||||
<TabsTrigger value="email">Alterar e-mail</TabsTrigger>
|
||||
<TabsTrigger value="deletar" className="text-destructive">
|
||||
Deletar conta
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Card className="p-6">
|
||||
<TabsContent value="nome" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar nome</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Atualize como seu nome aparece no OpenSheets. Esse nome pode ser
|
||||
exibido em diferentes seções do app e em comunicações.
|
||||
</p>
|
||||
</div>
|
||||
<UpdateNameForm currentName={userName} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="senha" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar senha</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Defina uma nova senha para sua conta. Guarde-a em local seguro.
|
||||
</p>
|
||||
</div>
|
||||
<UpdatePasswordForm />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="email" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">Alterar e-mail</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<UpdateEmailForm currentEmail={userEmail} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deletar" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1 text-destructive">
|
||||
Deletar conta
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
<DeleteAccountForm />
|
||||
</TabsContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
app/(dashboard)/anotacoes/actions.ts
Normal file
144
app/(dashboard)/anotacoes/actions.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
"use server";
|
||||
|
||||
import { anotacoes } from "@/db/schema";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
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";
|
||||
|
||||
const taskSchema = z.object({
|
||||
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 createNoteSchema = noteBaseSchema;
|
||||
const updateNoteSchema = noteBaseSchema.and(z.object({
|
||||
id: uuidSchema("Anotação"),
|
||||
}));
|
||||
const deleteNoteSchema = z.object({
|
||||
id: uuidSchema("Anotação"),
|
||||
});
|
||||
|
||||
type NoteCreateInput = z.infer<typeof createNoteSchema>;
|
||||
type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
|
||||
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
|
||||
|
||||
export async function createNoteAction(
|
||||
input: NoteCreateInput
|
||||
): Promise<ActionResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
revalidateForEntity("anotacoes");
|
||||
|
||||
return { success: true, message: "Anotação criada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNoteAction(
|
||||
input: NoteUpdateInput
|
||||
): Promise<ActionResult> {
|
||||
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 });
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Anotação não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("anotacoes");
|
||||
|
||||
return { success: true, message: "Anotação atualizada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNoteAction(
|
||||
input: NoteDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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 });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Anotação não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("anotacoes");
|
||||
|
||||
return { success: true, message: "Anotação removida com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
48
app/(dashboard)/anotacoes/data.ts
Normal file
48
app/(dashboard)/anotacoes/data.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { anotacoes, type Anotacao } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export type NoteData = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: "nota" | "tarefa";
|
||||
tasks?: Task[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
|
||||
const noteRows = await db.query.anotacoes.findMany({
|
||||
where: eq(anotacoes.userId, userId),
|
||||
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
title: (note.title ?? "").trim(),
|
||||
description: (note.description ?? "").trim(),
|
||||
type: (note.type ?? "nota") as "nota" | "tarefa",
|
||||
tasks,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
23
app/(dashboard)/anotacoes/layout.tsx
Normal file
23
app/(dashboard)/anotacoes/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiFileListLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Anotações | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiFileListLine />}
|
||||
title="Notas"
|
||||
subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
51
app/(dashboard)/anotacoes/loading.tsx
Normal file
51
app/(dashboard)/anotacoes/loading.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de anotações
|
||||
* Layout: Header com botão + Grid de cards de notas
|
||||
*/
|
||||
export default function AnotacoesLoading() {
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de cards de notas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-4 space-y-3"
|
||||
>
|
||||
{/* Título */}
|
||||
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Conteúdo (3-4 linhas) */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-2/3 rounded-2xl bg-foreground/10" />
|
||||
{i % 2 === 0 && (
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer com data e ações */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
14
app/(dashboard)/anotacoes/page.tsx
Normal file
14
app/(dashboard)/anotacoes/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NotesPage } from "@/components/anotacoes/notes-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchNotesForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const notes = await fetchNotesForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<NotesPage notes={notes} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
212
app/(dashboard)/calendario/data.ts
Normal file
212
app/(dashboard)/calendario/data.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { cartoes, lancamentos } from "@/db/schema";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
mapLancamentosData,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { db } from "@/lib/db";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
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";
|
||||
|
||||
const PAYMENT_METHOD_BOLETO = "Boleto";
|
||||
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);
|
||||
|
||||
if (Number.isNaN(year) || Number.isNaN(month) || month < 1 || month > 12) {
|
||||
throw new Error(`Período inválido: ${period}`);
|
||||
}
|
||||
|
||||
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 isWithinRange = (value: string | null, start: string, end: string) => {
|
||||
if (!value) return false;
|
||||
return value >= start && value <= end;
|
||||
};
|
||||
|
||||
type FetchCalendarDataParams = {
|
||||
userId: string;
|
||||
period: string;
|
||||
};
|
||||
|
||||
export const fetchCalendarData = async ({
|
||||
userId,
|
||||
period,
|
||||
}: FetchCalendarDataParams): Promise<CalendarData> => {
|
||||
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 lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
const cardTotals = new Map<string, number>();
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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))
|
||||
);
|
||||
|
||||
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<CalendarEvent["type"], number> = {
|
||||
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);
|
||||
});
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const optionSets = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
23
app/(dashboard)/calendario/layout.tsx
Normal file
23
app/(dashboard)/calendario/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiCalendarEventLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Calendário | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiCalendarEventLine />}
|
||||
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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
59
app/(dashboard)/calendario/loading.tsx
Normal file
59
app/(dashboard)/calendario/loading.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de calendário
|
||||
* Layout: MonthPicker + Grade mensal 7x5/6 com dias e eventos
|
||||
*/
|
||||
export default function CalendarioLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-3">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Calendar Container */}
|
||||
<div className="rounded-2xl border p-4 space-y-4">
|
||||
{/* Cabeçalho com dias da semana */}
|
||||
<div className="grid grid-cols-7 gap-2 mb-4">
|
||||
{["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => (
|
||||
<div key={day} className="text-center">
|
||||
<Skeleton className="h-4 w-12 mx-auto rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grade de dias (6 semanas) */}
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-h-[100px] rounded-2xl border p-2 space-y-2"
|
||||
>
|
||||
{/* Número do dia */}
|
||||
<Skeleton className="h-5 w-6 rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Indicadores de eventos (aleatório entre 0-3) */}
|
||||
{i % 3 === 0 && (
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
{i % 5 === 0 && (
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legenda */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-4 border-t">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="size-3 rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
app/(dashboard)/calendario/page.tsx
Normal file
47
app/(dashboard)/calendario/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
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<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const userId = await getUserId();
|
||||
const resolvedParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
const periodoParam = getSingleParam(resolvedParams, "periodo");
|
||||
const { period, monthName, year } = parsePeriodParam(periodoParam);
|
||||
|
||||
const calendarData = await fetchCalendarData({
|
||||
userId,
|
||||
period,
|
||||
});
|
||||
|
||||
const calendarPeriod: CalendarPeriod = {
|
||||
period,
|
||||
monthName,
|
||||
year,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-3">
|
||||
<MonthPicker />
|
||||
<MonthlyCalendar
|
||||
period={calendarPeriod}
|
||||
events={calendarData.events}
|
||||
formOptions={calendarData.formOptions}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
299
app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts
Normal file
299
app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
"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 { and, eq, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
type UpdateInvoicePaymentStatusInput = z.infer<
|
||||
typeof updateInvoicePaymentStatusSchema
|
||||
>;
|
||||
|
||||
type ActionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
const successMessageByStatus: Record<InvoicePaymentStatus, string> = {
|
||||
[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);
|
||||
|
||||
export async function updateInvoicePaymentStatusAction(
|
||||
input: UpdateInvoicePaymentStatusInput
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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)
|
||||
),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
if (shouldMarkAsPaid) {
|
||||
const [adminShareRow] = await tx
|
||||
.select({
|
||||
total: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
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)
|
||||
),
|
||||
});
|
||||
|
||||
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
|
||||
? new Date(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 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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: 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." }),
|
||||
});
|
||||
|
||||
type UpdatePaymentDateInput = z.infer<typeof updatePaymentDateSchema>;
|
||||
|
||||
export async function updatePaymentDateAction(
|
||||
input: UpdatePaymentDateInput
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
if (!card) {
|
||||
throw new Error("Cartão não encontrado.");
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingPayment) {
|
||||
throw new Error("Pagamento não encontrado.");
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(lancamentos)
|
||||
.set({
|
||||
purchaseDate: new Date(data.paymentDate),
|
||||
})
|
||||
.where(eq(lancamentos.id, existingPayment.id));
|
||||
});
|
||||
|
||||
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: false,
|
||||
error: error instanceof Error ? error.message : "Erro inesperado.",
|
||||
};
|
||||
}
|
||||
}
|
||||
104
app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts
Normal file
104
app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cartoes, faturas, lancamentos } from "@/db/schema";
|
||||
import { buildInvoicePaymentNote } from "@/lib/accounts/constants";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
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;
|
||||
};
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export async function fetchInvoiceData(
|
||||
userId: string,
|
||||
cartaoId: string,
|
||||
selectedPeriod: string
|
||||
): Promise<{
|
||||
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 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;
|
||||
|
||||
// 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 };
|
||||
}
|
||||
41
app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx
Normal file
41
app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
FilterSkeleton,
|
||||
InvoiceSummaryCardSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de fatura de cartão
|
||||
* Layout: MonthPicker + InvoiceSummaryCard + Filtros + Tabela de lançamentos
|
||||
*/
|
||||
export default function FaturaLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Invoice Summary Card */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<InvoiceSummaryCardSkeleton />
|
||||
</section>
|
||||
|
||||
{/* Seção de lançamentos */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<FilterSkeleton />
|
||||
|
||||
{/* Tabela */}
|
||||
<TransactionsTableSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
199
app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx
Normal file
199
app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { CardDialog } from "@/components/cartoes/card-dialog";
|
||||
import type { Card } from "@/components/cartoes/types";
|
||||
import { InvoiceSummaryCard } from "@/components/faturas/invoice-summary-card";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { lancamentos, type Conta } 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,
|
||||
} 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";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
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 periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const {
|
||||
period: selectedPeriod,
|
||||
monthName,
|
||||
year,
|
||||
} = parsePeriodParam(periodoParamRaw);
|
||||
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
|
||||
const card = await fetchCardData(userId, cartaoId);
|
||||
|
||||
if (!card) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [filterSources, logoOptions, invoiceData] = await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchInvoiceData(userId, cartaoId, selectedPeriod),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
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 lancamentosData = mapLancamentosData(lancamentoRows);
|
||||
|
||||
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 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 { 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}`;
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<InvoiceSummaryCard
|
||||
cartaoId={card.id}
|
||||
period={selectedPeriod}
|
||||
cardName={card.name}
|
||||
cardBrand={card.brand ?? null}
|
||||
cardStatus={card.status ?? null}
|
||||
closingDay={card.closingDay}
|
||||
dueDay={card.dueDay}
|
||||
periodLabel={periodLabel}
|
||||
totalAmount={totalAmount}
|
||||
limitAmount={limitAmount}
|
||||
invoiceStatus={invoiceStatus}
|
||||
paymentDate={paymentDate}
|
||||
logo={card.logo}
|
||||
actions={
|
||||
<CardDialog
|
||||
mode="update"
|
||||
card={cardDialogData}
|
||||
logoOptions={logoOptions}
|
||||
accounts={accountOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar cartão"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
allowCreate
|
||||
defaultCartaoId={card.id}
|
||||
defaultPaymentMethod="Cartão de crédito"
|
||||
lockCartaoSelection
|
||||
lockPaymentMethod
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
165
app/(dashboard)/cartoes/actions.ts
Normal file
165
app/(dashboard)/cartoes/actions.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
"use server";
|
||||
|
||||
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,
|
||||
} 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"),
|
||||
});
|
||||
|
||||
const createCardSchema = cardBaseSchema;
|
||||
const updateCardSchema = cardBaseSchema.extend({
|
||||
id: uuidSchema("Cartão"),
|
||||
});
|
||||
const deleteCardSchema = z.object({
|
||||
id: uuidSchema("Cartão"),
|
||||
});
|
||||
|
||||
type CardCreateInput = z.infer<typeof createCardSchema>;
|
||||
type CardUpdateInput = z.infer<typeof updateCardSchema>;
|
||||
type CardDeleteInput = z.infer<typeof deleteCardSchema>;
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error("Conta vinculada não encontrada.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCardAction(
|
||||
input: CardCreateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createCardSchema.parse(input);
|
||||
|
||||
await assertAccountOwnership(user.id, data.contaId);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCardAction(
|
||||
input: CardUpdateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateCardSchema.parse(input);
|
||||
|
||||
await assertAccountOwnership(user.id, data.contaId);
|
||||
|
||||
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();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cartão não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCardAction(
|
||||
input: CardDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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 });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cartão não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("cartoes");
|
||||
|
||||
return { success: true, message: "Cartão removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
110
app/(dashboard)/cartoes/data.ts
Normal file
110
app/(dashboard)/cartoes/data.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cartoes, contas, lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { loadLogoOptions } from "@/lib/logo/options";
|
||||
import { and, eq, isNull, 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;
|
||||
};
|
||||
|
||||
export type AccountSimple = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export async function fetchCardsForUser(userId: string): Promise<{
|
||||
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: eq(cartoes.userId, userId),
|
||||
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<number>`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<string, number>();
|
||||
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 accounts = accountRows.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
logo: account.logo,
|
||||
}));
|
||||
|
||||
return { cards, accounts, logoOptions };
|
||||
}
|
||||
25
app/(dashboard)/cartoes/layout.tsx
Normal file
25
app/(dashboard)/cartoes/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankCardLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Cartões | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiBankCardLine />}
|
||||
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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
app/(dashboard)/cartoes/loading.tsx
Normal file
33
app/(dashboard)/cartoes/loading.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de cartões
|
||||
*/
|
||||
export default function CartoesLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de cartões */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
14
app/(dashboard)/cartoes/page.tsx
Normal file
14
app/(dashboard)/cartoes/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CardsPage } from "@/components/cartoes/cards-page";
|
||||
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);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<CardsPage cards={cards} accounts={accounts} logoOptions={logoOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
115
app/(dashboard)/categorias/[categoryId]/page.tsx
Normal file
115
app/(dashboard)/categorias/[categoryId]/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { getRecentEstablishmentsAction } from "@/app/(dashboard)/lancamentos/actions";
|
||||
import { CategoryDetailHeader } from "@/components/categorias/category-detail-header";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { fetchCategoryDetails } from "@/lib/dashboard/categories/category-details";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
buildOptionSets,
|
||||
buildSluggedFilters,
|
||||
fetchLancamentoFilterSources,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ categoryId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
) => {
|
||||
const value = params?.[key];
|
||||
if (!value) return null;
|
||||
return Array.isArray(value) ? value[0] ?? null : value;
|
||||
};
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [yearStr, monthStr] = period.split("-");
|
||||
const year = Number.parseInt(yearStr ?? "", 10);
|
||||
const monthIndex = Number.parseInt(monthStr ?? "", 10) - 1;
|
||||
|
||||
if (Number.isNaN(year) || Number.isNaN(monthIndex) || monthIndex < 0) {
|
||||
return period;
|
||||
}
|
||||
|
||||
const date = new Date(year, monthIndex, 1);
|
||||
const label = date.toLocaleDateString("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
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 [detail, filterSources, estabelecimentos] = await Promise.all([
|
||||
fetchCategoryDetails(userId, categoryId, selectedPeriod),
|
||||
fetchLancamentoFilterSources(userId),
|
||||
getRecentEstablishmentsAction(),
|
||||
]);
|
||||
|
||||
if (!detail) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
});
|
||||
|
||||
const currentPeriodLabel = formatPeriodLabel(detail.period);
|
||||
const previousPeriodLabel = formatPeriodLabel(detail.previousPeriod);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
<CategoryDetailHeader
|
||||
category={detail.category}
|
||||
currentPeriodLabel={currentPeriodLabel}
|
||||
previousPeriodLabel={previousPeriodLabel}
|
||||
currentTotal={detail.currentTotal}
|
||||
previousTotal={detail.previousTotal}
|
||||
percentageChange={detail.percentageChange}
|
||||
transactionCount={detail.transactions.length}
|
||||
/>
|
||||
<LancamentosPage
|
||||
lancamentos={detail.transactions}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={detail.period}
|
||||
estabelecimentos={estabelecimentos}
|
||||
allowCreate={true}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
176
app/(dashboard)/categorias/actions.ts
Normal file
176
app/(dashboard)/categorias/actions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use server";
|
||||
|
||||
import { categorias } from "@/db/schema";
|
||||
import {
|
||||
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)),
|
||||
});
|
||||
|
||||
const createCategorySchema = categoryBaseSchema;
|
||||
const updateCategorySchema = categoryBaseSchema.extend({
|
||||
id: uuidSchema("Categoria"),
|
||||
});
|
||||
const deleteCategorySchema = z.object({
|
||||
id: uuidSchema("Categoria"),
|
||||
});
|
||||
|
||||
type CategoryCreateInput = z.infer<typeof createCategorySchema>;
|
||||
type CategoryUpdateInput = z.infer<typeof updateCategorySchema>;
|
||||
type CategoryDeleteInput = z.infer<typeof deleteCategorySchema>;
|
||||
|
||||
export async function createCategoryAction(
|
||||
input: CategoryCreateInput
|
||||
): Promise<ActionResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria criada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCategoryAction(
|
||||
input: CategoryUpdateInput
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria atualizada com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(
|
||||
input: CategoryDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("categorias");
|
||||
|
||||
return { success: true, message: "Categoria removida com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
26
app/(dashboard)/categorias/data.ts
Normal file
26
app/(dashboard)/categorias/data.ts
Normal file
@@ -0,0 +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";
|
||||
|
||||
export type CategoryData = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CategoryType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
export async function fetchCategoriesForUser(
|
||||
userId: string
|
||||
): Promise<CategoryData[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
23
app/(dashboard)/categorias/layout.tsx
Normal file
23
app/(dashboard)/categorias/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiPriceTag3Line } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Categorias | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiPriceTag3Line />}
|
||||
title="Categorias"
|
||||
subtitle="Gerencie suas categorias de despesas e receitas. Acompanhe o desempenho financeiro por categoria e faça ajustes conforme necessário."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
61
app/(dashboard)/categorias/loading.tsx
Normal file
61
app/(dashboard)/categorias/loading.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de categorias
|
||||
* Layout: Header + Tabs + Grid de cards
|
||||
*/
|
||||
export default function CategoriasLoading() {
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 border-b">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-10 w-32 rounded-t-2xl bg-foreground/10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid de cards de categorias */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
{/* Ícone + Nome */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-2xl bg-foreground/10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
{i % 3 === 0 && (
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
)}
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
14
app/(dashboard)/categorias/page.tsx
Normal file
14
app/(dashboard)/categorias/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CategoriesPage } from "@/components/categorias/categories-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchCategoriesForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const categories = await fetchCategoriesForUser(userId);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<CategoriesPage categories={categories} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
131
app/(dashboard)/contas/[contaId]/extrato/data.ts
Normal file
131
app/(dashboard)/contas/[contaId]/extrato/data.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function fetchAccountSummary(
|
||||
userId: string,
|
||||
contaId: string,
|
||||
selectedPeriod: string
|
||||
): Promise<AccountSummaryData> {
|
||||
const [periodSummary] = await db
|
||||
.select({
|
||||
netAmount: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
incomes: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
expenses: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
when ${lancamentos.transactionType} = 'Despesa' then ${lancamentos.amount}
|
||||
else 0
|
||||
end
|
||||
),
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
const [previousRow] = await db
|
||||
.select({
|
||||
previousMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
openingBalance,
|
||||
currentBalance,
|
||||
totalIncomes,
|
||||
totalExpenses,
|
||||
};
|
||||
}
|
||||
38
app/(dashboard)/contas/[contaId]/extrato/loading.tsx
Normal file
38
app/(dashboard)/contas/[contaId]/extrato/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
AccountStatementCardSkeleton,
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de extrato de conta
|
||||
* Layout: MonthPicker + AccountStatementCard + Filtros + Tabela de lançamentos
|
||||
*/
|
||||
export default function ExtratoLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Account Statement Card */}
|
||||
<AccountStatementCardSkeleton />
|
||||
|
||||
{/* Seção de lançamentos */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<FilterSkeleton />
|
||||
|
||||
{/* Tabela */}
|
||||
<TransactionsTableSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
173
app/(dashboard)/contas/[contaId]/extrato/page.tsx
Normal file
173
app/(dashboard)/contas/[contaId]/extrato/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { AccountDialog } from "@/components/contas/account-dialog";
|
||||
import { AccountStatementCard } from "@/components/contas/account-statement-card";
|
||||
import type { Account } from "@/components/contas/types";
|
||||
import { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
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,
|
||||
} 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";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ contaId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const capitalize = (value: string) =>
|
||||
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 periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const {
|
||||
period: selectedPeriod,
|
||||
monthName,
|
||||
year,
|
||||
} = parsePeriodParam(periodoParamRaw);
|
||||
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
|
||||
const account = await fetchAccountData(userId, contaId);
|
||||
|
||||
if (!account) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [filterSources, logoOptions, accountSummary] = await Promise.all([
|
||||
fetchLancamentoFilterSources(userId),
|
||||
loadLogoOptions(),
|
||||
fetchAccountSummary(userId, contaId, selectedPeriod),
|
||||
]);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
const filters = buildLancamentoWhere({
|
||||
userId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
accountId: account.id,
|
||||
});
|
||||
|
||||
filters.push(eq(lancamentos.isSettled, true));
|
||||
|
||||
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 { openingBalance, currentBalance, totalIncomes, totalExpenses } =
|
||||
accountSummary;
|
||||
|
||||
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 {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions,
|
||||
defaultPagadorId,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
} = buildOptionSets({
|
||||
...sluggedFilters,
|
||||
pagadorRows: filterSources.pagadorRows,
|
||||
limitContaId: account.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
|
||||
<AccountStatementCard
|
||||
accountName={account.name}
|
||||
accountType={account.accountType}
|
||||
status={account.status}
|
||||
periodLabel={periodLabel}
|
||||
openingBalance={openingBalance}
|
||||
currentBalance={currentBalance}
|
||||
totalIncomes={totalIncomes}
|
||||
totalExpenses={totalExpenses}
|
||||
logo={account.logo}
|
||||
actions={
|
||||
<AccountDialog
|
||||
mode="update"
|
||||
account={accountDialogData}
|
||||
logoOptions={logoOptions}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="Editar conta"
|
||||
>
|
||||
<RiPencilLine className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
allowCreate={false}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
383
app/(dashboard)/contas/actions.ts
Normal file
383
app/(dashboard)/contas/actions.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
"use server";
|
||||
|
||||
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,
|
||||
} from "@/lib/accounts/constants";
|
||||
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
|
||||
import { revalidateForEntity } from "@/lib/actions/helpers";
|
||||
import { noteSchema, uuidSchema } from "@/lib/schemas/common";
|
||||
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"),
|
||||
});
|
||||
|
||||
const createAccountSchema = accountBaseSchema;
|
||||
const updateAccountSchema = accountBaseSchema.extend({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
const deleteAccountSchema = z.object({
|
||||
id: uuidSchema("Conta"),
|
||||
});
|
||||
|
||||
type AccountCreateInput = z.infer<typeof createAccountSchema>;
|
||||
type AccountUpdateInput = z.infer<typeof updateAccountSchema>;
|
||||
type AccountDeleteInput = z.infer<typeof deleteAccountSchema>;
|
||||
|
||||
export async function createAccountAction(
|
||||
input: AccountCreateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createAccountSchema.parse(input);
|
||||
|
||||
const logoFile = normalizeFilePath(data.logo);
|
||||
|
||||
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,
|
||||
userId: user.id,
|
||||
})
|
||||
.returning({ id: contas.id, name: contas.name });
|
||||
|
||||
if (!createdAccount) {
|
||||
throw new Error("Não foi possível criar a conta.");
|
||||
}
|
||||
|
||||
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)
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta criada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
input: AccountUpdateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateAccountSchema.parse(input);
|
||||
|
||||
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,
|
||||
})
|
||||
.where(and(eq(contas.id, data.id), eq(contas.userId, user.id)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Conta atualizada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
input: AccountDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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 });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Conta não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("contas");
|
||||
|
||||
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."),
|
||||
});
|
||||
|
||||
type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function transferBetweenAccountsAction(
|
||||
input: TransferInput
|
||||
): Promise<ActionResult> {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
// 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)
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!fromAccount) {
|
||||
throw new Error("Conta de origem 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)
|
||||
),
|
||||
});
|
||||
|
||||
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)
|
||||
),
|
||||
});
|
||||
|
||||
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 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");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Transferência registrada com sucesso.",
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
95
app/(dashboard)/contas/data.ts
Normal file
95
app/(dashboard)/contas/data.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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, 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;
|
||||
};
|
||||
|
||||
export async function fetchAccountsForUser(
|
||||
userId: string,
|
||||
currentPeriod: 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,
|
||||
balanceMovements: sql<number>`
|
||||
coalesce(
|
||||
sum(
|
||||
case
|
||||
when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0
|
||||
else ${lancamentos.amount}
|
||||
end
|
||||
),
|
||||
0
|
||||
)
|
||||
`,
|
||||
})
|
||||
.from(contas)
|
||||
.leftJoin(
|
||||
lancamentos,
|
||||
and(
|
||||
eq(lancamentos.contaId, contas.id),
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, currentPeriod),
|
||||
eq(lancamentos.isSettled, true)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
pagadores,
|
||||
eq(lancamentos.pagadorId, pagadores.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(contas.userId, userId),
|
||||
sql`(${lancamentos.id} IS NULL OR ${pagadores.role} = ${PAGADOR_ROLE_ADMIN})`
|
||||
)
|
||||
)
|
||||
.groupBy(
|
||||
contas.id,
|
||||
contas.name,
|
||||
contas.accountType,
|
||||
contas.status,
|
||||
contas.note,
|
||||
contas.logo,
|
||||
contas.initialBalance,
|
||||
contas.excludeFromBalance
|
||||
),
|
||||
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,
|
||||
}));
|
||||
|
||||
return { accounts, logoOptions };
|
||||
}
|
||||
25
app/(dashboard)/contas/layout.tsx
Normal file
25
app/(dashboard)/contas/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiBankLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Contas | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiBankLine />}
|
||||
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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
36
app/(dashboard)/contas/loading.tsx
Normal file
36
app/(dashboard)/contas/loading.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de contas
|
||||
*/
|
||||
export default function ContasLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de contas */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-8 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
22
app/(dashboard)/contas/page.tsx
Normal file
22
app/(dashboard)/contas/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AccountsPage } from "@/components/contas/accounts-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { fetchAccountsForUser } from "./data";
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
const now = new Date();
|
||||
const currentPeriod = `${now.getFullYear()}-${String(
|
||||
now.getMonth() + 1
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
const { accounts, logoOptions } = await fetchAccountsForUser(
|
||||
userId,
|
||||
currentPeriod
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<AccountsPage accounts={accounts} logoOptions={logoOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
17
app/(dashboard)/dashboard/loading.tsx
Normal file
17
app/(dashboard)/dashboard/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DashboardGridSkeleton } from "@/components/skeletons";
|
||||
|
||||
/**
|
||||
* Loading state para a página do dashboard
|
||||
* Usa skeleton fiel ao layout final para evitar layout shift
|
||||
*/
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6 px-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Dashboard content skeleton */}
|
||||
<DashboardGridSkeleton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
40
app/(dashboard)/dashboard/page.tsx
Normal file
40
app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DashboardGrid } from "@/components/dashboard/dashboard-grid";
|
||||
import { DashboardWelcome } from "@/components/dashboard/dashboard-welcome";
|
||||
import { SectionCards } from "@/components/dashboard/section-cards";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { fetchDashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||
import { getUser } from "@/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
) => {
|
||||
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 data = await fetchDashboardData(user.id, selectedPeriod);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 px-4">
|
||||
<DashboardWelcome name={user.name} />
|
||||
<MonthPicker />
|
||||
<SectionCards metrics={data.metrics} />
|
||||
<DashboardGrid data={data} period={selectedPeriod} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
817
app/(dashboard)/insights/actions.ts
Normal file
817
app/(dashboard)/insights/actions.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
"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";
|
||||
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 { AVAILABLE_MODELS, INSIGHTS_SYSTEM_PROMPT } from "./data";
|
||||
|
||||
const TRANSFERENCIA = "Transferência";
|
||||
|
||||
type ActionResult<T> =
|
||||
| { 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, period),
|
||||
eq(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<number>`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<number>`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<number>`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;
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Buscar despesas por categoria (top 5)
|
||||
const expensesByCategory = await db
|
||||
.select({
|
||||
categoryName: categorias.name,
|
||||
total: sql<number>`coalesce(sum(${lancamentos.amount}), 0)`,
|
||||
})
|
||||
.from(lancamentos)
|
||||
.innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id))
|
||||
.innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id))
|
||||
.where(
|
||||
and(
|
||||
eq(lancamentos.userId, userId),
|
||||
eq(lancamentos.period, 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<number>`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<number>`coalesce(sum(${cartoes.limit}), 0)`,
|
||||
cardCount: sql<number>`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<number>`coalesce(sum(${contas.initialBalance}), 0)`,
|
||||
accountCount: sql<number>`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<number>`coalesce(avg(abs(${lancamentos.amount})), 0)`,
|
||||
transactionCount: sql<number>`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)
|
||||
)
|
||||
);
|
||||
|
||||
// Agregar por dia da semana
|
||||
const dayTotals = new Map<number, number>();
|
||||
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<number>`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);
|
||||
|
||||
// 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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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,
|
||||
|
||||
// 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,
|
||||
})
|
||||
),
|
||||
|
||||
// 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return aggregatedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera insights usando IA
|
||||
*/
|
||||
export async function generateInsightsAction(
|
||||
period: string,
|
||||
modelId: string
|
||||
): Promise<ActionResult<InsightsResponse>> {
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
||||
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}.
|
||||
|
||||
Dados agregados:
|
||||
${JSON.stringify(aggregatedData, null, 2)}
|
||||
|
||||
DADOS IMPORTANTES PARA SUA ANÁLISE:
|
||||
|
||||
**Tendência de 3 meses:**
|
||||
- Os dados incluem tendência dos últimos 3 meses (threeMonthTrend)
|
||||
- Use isso para identificar padrões crescentes, decrescentes ou estáveis
|
||||
- Compare o mês atual com a média dos 3 meses
|
||||
|
||||
**Análise de Recorrência:**
|
||||
- Gastos recorrentes representam ${aggregatedData.recurringExpenses.percentageOfTotal.toFixed(1)}% das despesas
|
||||
- ${aggregatedData.recurringExpenses.count} gastos identificados como recorrentes
|
||||
- Use isso para avaliar previsibilidade e oportunidades de otimização
|
||||
|
||||
**Gastos Parcelados:**
|
||||
- ${aggregatedData.installments.currentMonthInstallments} parcelas ativas no mês
|
||||
- Comprometimento futuro de R$ ${aggregatedData.installments.futureCommitment.toFixed(2)}
|
||||
- Use isso para alertas sobre comprometimento de renda futura
|
||||
|
||||
Organize suas observações nas 4 categorias especificadas no prompt do sistema:
|
||||
1. Comportamentos Observados (behaviors): 3-6 itens
|
||||
2. Gatilhos de Consumo (triggers): 3-6 itens
|
||||
3. Recomendações Práticas (recommendations): 3-6 itens
|
||||
4. Melhorias Sugeridas (improvements): 3-6 itens
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
): Promise<ActionResult<{ id: string; createdAt: Date }>> {
|
||||
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);
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega insights salvos do banco de dados
|
||||
*/
|
||||
export async function loadSavedInsightsAction(
|
||||
period: string
|
||||
): Promise<
|
||||
ActionResult<{
|
||||
insights: InsightsResponse;
|
||||
modelId: string;
|
||||
createdAt: Date;
|
||||
} | null>
|
||||
> {
|
||||
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);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const saved = result[0];
|
||||
if (!saved) {
|
||||
return {
|
||||
success: true,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove insights salvos do banco de dados
|
||||
*/
|
||||
export async function deleteSavedInsightsAction(
|
||||
period: string
|
||||
): Promise<ActionResult<void>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
145
app/(dashboard)/insights/data.ts
Normal file
145
app/(dashboard)/insights/data.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tipos de providers disponíveis
|
||||
*/
|
||||
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",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lista de modelos de IA disponíveis para análise de insights
|
||||
*/
|
||||
export const AVAILABLE_MODELS = [
|
||||
// OpenAI Models (5)
|
||||
{ id: "gpt-5", name: "GPT-5", provider: "openai" as const },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" as const },
|
||||
{ id: "gpt-5-nano", name: "GPT-5 Nano", provider: "openai" as const },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" as const },
|
||||
{ id: "gpt-4o", name: "GPT-4o (Omni)", provider: "openai" as const },
|
||||
|
||||
// Anthropic Models (5)
|
||||
{
|
||||
id: "claude-3.7-sonnet",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4-opus",
|
||||
name: "Claude 4 Opus",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4.5-sonnet",
|
||||
name: "Claude 4.5 Sonnet",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-4.5-haiku",
|
||||
name: "Claude 4.5 Haiku",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
{
|
||||
id: "claude-3.5-sonnet-20240620",
|
||||
name: "Claude 3.5 Sonnet (2024-06-20)",
|
||||
provider: "anthropic" as const,
|
||||
},
|
||||
|
||||
// Google Models (5)
|
||||
{
|
||||
id: "gemini-2.5-pro",
|
||||
name: "Gemini 2.5 Pro",
|
||||
provider: "google" as const,
|
||||
},
|
||||
{
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
provider: "google" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Modelo padrão
|
||||
*/
|
||||
export const DEFAULT_MODEL = "gpt-5";
|
||||
export const DEFAULT_PROVIDER = "openai";
|
||||
|
||||
/**
|
||||
* System prompt para análise de insights
|
||||
*/
|
||||
export const INSIGHTS_SYSTEM_PROMPT = `Você é um especialista em comportamento financeiro. Analise os dados financeiros fornecidos e organize suas observações em 4 categorias específicas:
|
||||
|
||||
1. **Comportamentos Observados** (behaviors): Padrões de gastos e hábitos financeiros identificados nos dados. Foque em comportamentos recorrentes e tendências. Considere:
|
||||
- Tendência dos últimos 3 meses (crescente, decrescente, estável)
|
||||
- Gastos recorrentes e sua previsibilidade
|
||||
- Padrões de parcelamento e comprometimento futuro
|
||||
|
||||
2. **Gatilhos de Consumo** (triggers): Identifique situações, períodos ou categorias que desencadeiam maiores gastos. O que leva o usuário a gastar mais? Analise:
|
||||
- Dias da semana com mais gastos
|
||||
- Categorias que cresceram nos últimos meses
|
||||
- Métodos de pagamento que facilitam gastos
|
||||
|
||||
3. **Recomendações Práticas** (recommendations): Sugestões concretas e acionáveis para melhorar a saúde financeira. Seja específico e direto. Use os dados de:
|
||||
- Gastos recorrentes que podem ser otimizados
|
||||
- Orçamentos que estão sendo ultrapassados
|
||||
- Comprometimento futuro com parcelamentos
|
||||
|
||||
4. **Melhorias Sugeridas** (improvements): Oportunidades de otimização e estratégias de longo prazo para alcançar objetivos financeiros. Considere:
|
||||
- Tendências preocupantes dos últimos 3 meses
|
||||
- Percentual de gastos recorrentes vs pontuais
|
||||
- Estratégias para reduzir comprometimento futuro
|
||||
|
||||
Para cada categoria, forneça de 3 a 6 itens concisos e objetivos. Use linguagem clara e direta, com verbos de ação. Mantenha privacidade e não exponha dados pessoais sensíveis.
|
||||
|
||||
IMPORTANTE: Utilize os novos dados disponíveis (threeMonthTrend, recurringExpenses, installments) para fornecer insights mais ricos e contextualizados.
|
||||
|
||||
Responda EXCLUSIVAMENTE com um JSON válido seguindo o esquema:
|
||||
{
|
||||
"month": "YYYY-MM",
|
||||
"generatedAt": "ISO datetime",
|
||||
"categories": [
|
||||
{
|
||||
"category": "behaviors",
|
||||
"items": [
|
||||
{ "text": "Observação aqui" },
|
||||
...
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "triggers",
|
||||
"items": [...]
|
||||
},
|
||||
{
|
||||
"category": "recommendations",
|
||||
"items": [...]
|
||||
},
|
||||
{
|
||||
"category": "improvements",
|
||||
"items": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
`;
|
||||
23
app/(dashboard)/insights/layout.tsx
Normal file
23
app/(dashboard)/insights/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiSparklingLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Insights | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiSparklingLine />}
|
||||
title="Insights"
|
||||
subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
app/(dashboard)/insights/loading.tsx
Normal file
42
app/(dashboard)/insights/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de insights com IA
|
||||
*/
|
||||
export default function InsightsLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-64 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-96 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de insights */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="size-8 rounded-full bg-foreground/10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-2/3 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
31
app/(dashboard)/insights/page.tsx
Normal file
31
app/(dashboard)/insights/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { InsightsPage } from "@/components/insights/insights-page";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
<InsightsPage period={selectedPeriod} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
1403
app/(dashboard)/lancamentos/actions.ts
Normal file
1403
app/(dashboard)/lancamentos/actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
471
app/(dashboard)/lancamentos/anticipation-actions.ts
Normal file
471
app/(dashboard)/lancamentos/anticipation-actions.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
"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";
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema de validação para cancelar antecipação
|
||||
*/
|
||||
const cancelAnticipationSchema = z.object({
|
||||
anticipationId: uuidSchema("Antecipação"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Busca parcelas elegíveis para antecipação de uma série
|
||||
*/
|
||||
export async function getEligibleInstallmentsAction(
|
||||
seriesId: string
|
||||
): Promise<ActionResult<EligibleInstallment[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria uma antecipação de parcelas
|
||||
*/
|
||||
export async function createInstallmentAnticipationAction(
|
||||
input: CreateAnticipationInput
|
||||
): Promise<ActionResult> {
|
||||
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)
|
||||
),
|
||||
});
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
// 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.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
|
||||
|
||||
// 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.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));
|
||||
});
|
||||
|
||||
revalidatePath("/lancamentos");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
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
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations[]>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// 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));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: anticipations,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela uma antecipação de parcelas
|
||||
* Remove o lançamento de antecipação e restaura as parcelas originais
|
||||
*/
|
||||
export async function cancelInstallmentAnticipationAction(
|
||||
input: CancelAnticipationInput
|
||||
): Promise<ActionResult> {
|
||||
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);
|
||||
|
||||
const anticipation = anticipationRows[0];
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
// 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[]
|
||||
)
|
||||
);
|
||||
|
||||
// 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));
|
||||
});
|
||||
|
||||
revalidatePath("/lancamentos");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
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
|
||||
): Promise<ActionResult<InstallmentAnticipationWithRelations>> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
if (!anticipation) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Antecipação não encontrada.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: anticipation,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
18
app/(dashboard)/lancamentos/data.ts
Normal file
18
app/(dashboard)/lancamentos/data.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { lancamentos } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, desc, type SQL } from "drizzle-orm";
|
||||
|
||||
export async function fetchLancamentos(filters: SQL[]) {
|
||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: [desc(lancamentos.purchaseDate), desc(lancamentos.createdAt)],
|
||||
});
|
||||
|
||||
return lancamentoRows;
|
||||
}
|
||||
25
app/(dashboard)/lancamentos/layout.tsx
Normal file
25
app/(dashboard)/lancamentos/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiArrowLeftRightLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Lançamentos | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiArrowLeftRightLine />}
|
||||
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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
app/(dashboard)/lancamentos/loading.tsx
Normal file
32
app/(dashboard)/lancamentos/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
FilterSkeleton,
|
||||
TransactionsTableSkeleton,
|
||||
} from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de lançamentos
|
||||
* Mantém o mesmo layout da página final
|
||||
*/
|
||||
export default function LancamentosLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header com título e botão */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<FilterSkeleton />
|
||||
|
||||
{/* Tabela */}
|
||||
<TransactionsTableSkeleton />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
84
app/(dashboard)/lancamentos/page.tsx
Normal file
84
app/(dashboard)/lancamentos/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { LancamentosPage } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import {
|
||||
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";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
const periodoParamRaw = getSingleParam(resolvedSearchParams, "periodo");
|
||||
const { period: selectedPeriod } = parsePeriodParam(periodoParamRaw);
|
||||
|
||||
const searchFilters = extractLancamentoSearchFilters(resolvedSearchParams);
|
||||
|
||||
const filterSources = await fetchLancamentoFilterSources(userId);
|
||||
const sluggedFilters = buildSluggedFilters(filterSources);
|
||||
const slugMaps = buildSlugMaps(sluggedFilters);
|
||||
|
||||
const filters = buildLancamentoWhere({
|
||||
userId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
});
|
||||
|
||||
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 estabelecimentos = await getRecentEstablishmentsAction();
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
<LancamentosPage
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={pagadorOptions}
|
||||
splitPagadorOptions={splitPagadorOptions}
|
||||
defaultPagadorId={defaultPagadorId}
|
||||
contaOptions={contaOptions}
|
||||
cartaoOptions={cartaoOptions}
|
||||
categoriaOptions={categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
estabelecimentos={estabelecimentos}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
67
app/(dashboard)/layout.tsx
Normal file
67
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { SiteHeader } from "@/components/header-dashboard";
|
||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
||||
import { getUserSession } from "@/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { PAGADOR_ROLE_ADMIN } from "@/lib/pagadores/constants";
|
||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||
|
||||
export default async function layout({
|
||||
children,
|
||||
searchParams,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}>) {
|
||||
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
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
user={{ ...session.user, image: session.user.image ?? null }}
|
||||
pagadorAvatarUrl={adminPagador?.avatarUrl ?? null}
|
||||
pagadores={pagadoresList.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
avatarUrl: item.avatarUrl,
|
||||
canEdit: item.canEdit,
|
||||
}))}
|
||||
variant="inset"
|
||||
/>
|
||||
<SidebarInset>
|
||||
<SiteHeader notificationsSnapshot={notificationsSnapshot} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
190
app/(dashboard)/orcamentos/actions.ts
Normal file
190
app/(dashboard)/orcamentos/actions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
"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";
|
||||
|
||||
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."
|
||||
),
|
||||
});
|
||||
|
||||
const createBudgetSchema = budgetBaseSchema;
|
||||
const updateBudgetSchema = budgetBaseSchema.extend({
|
||||
id: uuidSchema("Orçamento"),
|
||||
});
|
||||
const deleteBudgetSchema = z.object({
|
||||
id: uuidSchema("Orçamento"),
|
||||
});
|
||||
|
||||
type BudgetCreateInput = z.infer<typeof createBudgetSchema>;
|
||||
type BudgetUpdateInput = z.infer<typeof updateBudgetSchema>;
|
||||
type BudgetDeleteInput = z.infer<typeof deleteBudgetSchema>;
|
||||
|
||||
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)),
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new Error("Categoria não encontrada.");
|
||||
}
|
||||
|
||||
if (category.type !== "despesa") {
|
||||
throw new Error("Selecione uma categoria de despesa.");
|
||||
}
|
||||
};
|
||||
|
||||
export async function createBudgetAction(
|
||||
input: BudgetCreateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = createBudgetSchema.parse(input);
|
||||
|
||||
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 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.",
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(orcamentos).values({
|
||||
amount: formatDecimalForDbRequired(data.amount),
|
||||
period: data.period,
|
||||
userId: user.id,
|
||||
categoriaId: data.categoriaId,
|
||||
});
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBudgetAction(
|
||||
input: BudgetUpdateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateBudgetSchema.parse(input);
|
||||
|
||||
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 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.",
|
||||
};
|
||||
}
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBudgetAction(
|
||||
input: BudgetDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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 });
|
||||
|
||||
if (!deleted) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Orçamento não encontrado.",
|
||||
};
|
||||
}
|
||||
|
||||
revalidateForEntity("orcamentos");
|
||||
|
||||
return { success: true, message: "Orçamento removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
125
app/(dashboard)/orcamentos/data.ts
Normal file
125
app/(dashboard)/orcamentos/data.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
categorias,
|
||||
lancamentos,
|
||||
orcamentos,
|
||||
type Orcamento,
|
||||
} 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;
|
||||
};
|
||||
|
||||
export type BudgetData = {
|
||||
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;
|
||||
};
|
||||
|
||||
export async function fetchBudgetsForUser(
|
||||
userId: string,
|
||||
selectedPeriod: string
|
||||
): Promise<{
|
||||
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 categoryIds = budgetRows
|
||||
.map((budget: Orcamento) => budget.categoriaId)
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
|
||||
let totalsByCategory = new Map<string, number>();
|
||||
|
||||
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)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
return { budgets, categoriesOptions };
|
||||
}
|
||||
23
app/(dashboard)/orcamentos/layout.tsx
Normal file
23
app/(dashboard)/orcamentos/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiFundsLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Anotações | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiFundsLine />}
|
||||
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}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
app/(dashboard)/orcamentos/loading.tsx
Normal file
68
app/(dashboard)/orcamentos/loading.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de orçamentos
|
||||
* Layout: MonthPicker + Header + Grid de cards de orçamento
|
||||
*/
|
||||
export default function OrcamentosLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Grid de cards de orçamentos */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border p-6 space-y-4"
|
||||
>
|
||||
{/* Categoria com ícone */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-2xl bg-foreground/10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valor orçado */}
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-7 w-32 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Valor gasto */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-28 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Barra de progresso */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-2 w-full rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-3 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
55
app/(dashboard)/orcamentos/page.tsx
Normal file
55
app/(dashboard)/orcamentos/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { BudgetsPage } from "@/components/orcamentos/budgets-page";
|
||||
import { getUserId } from "@/lib/auth/server";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { fetchBudgetsForUser } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
type PageProps = {
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const getSingleParam = (
|
||||
params: Record<string, string | string[] | undefined> | undefined,
|
||||
key: string
|
||||
) => {
|
||||
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);
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
const periodoParam = getSingleParam(resolvedSearchParams, "periodo");
|
||||
|
||||
const {
|
||||
period: selectedPeriod,
|
||||
monthName: rawMonthName,
|
||||
year,
|
||||
} = parsePeriodParam(periodoParam);
|
||||
|
||||
const periodLabel = `${capitalize(rawMonthName)} ${year}`;
|
||||
|
||||
const { budgets, categoriesOptions } = await fetchBudgetsForUser(
|
||||
userId,
|
||||
selectedPeriod
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
<BudgetsPage
|
||||
budgets={budgets}
|
||||
categories={categoriesOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
periodLabel={periodLabel}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
612
app/(dashboard)/pagadores/[pagadorId]/actions.ts
Normal file
612
app/(dashboard)/pagadores/[pagadorId]/actions.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
"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 { and, desc, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
|
||||
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."),
|
||||
});
|
||||
|
||||
type ActionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
value.toLocaleString("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const formatPeriodLabel = (period: string) => {
|
||||
const [yearStr, monthStr] = period.split("-");
|
||||
const year = Number.parseInt(yearStr, 10);
|
||||
const month = Number.parseInt(monthStr, 10) - 1;
|
||||
const date = new Date(year, month, 1);
|
||||
return date.toLocaleDateString("pt-BR", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (value: Date | null | undefined) => {
|
||||
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, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
type LancamentoRow = {
|
||||
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;
|
||||
};
|
||||
|
||||
type ParceladoItem = {
|
||||
name: string;
|
||||
totalAmount: number;
|
||||
installmentCount: number;
|
||||
currentInstallment: number;
|
||||
installmentAmount: number;
|
||||
purchaseDate: Date | null;
|
||||
};
|
||||
|
||||
type SummaryPayload = {
|
||||
pagadorName: string;
|
||||
periodLabel: string;
|
||||
monthlyBreakdown: Awaited<ReturnType<typeof fetchPagadorMonthlyBreakdown>>;
|
||||
historyData: Awaited<ReturnType<typeof fetchPagadorHistory>>;
|
||||
cardUsage: Awaited<ReturnType<typeof fetchPagadorCardUsage>>;
|
||||
boletoStats: Awaited<ReturnType<typeof fetchPagadorBoletoStats>>;
|
||||
boletos: BoletoItem[];
|
||||
lancamentos: LancamentoRow[];
|
||||
parcelados: ParceladoItem[];
|
||||
};
|
||||
|
||||
const buildSectionHeading = (label: string) =>
|
||||
`<h3 style="font-size:16px;margin:24px 0 8px 0;color:#0f172a;">${label}</h3>`;
|
||||
|
||||
const buildSummaryHtml = ({
|
||||
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);
|
||||
|
||||
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 `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
point.label
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="flex:1;background:#f1f5f9;border-radius:6px;height:24px;overflow:hidden;">
|
||||
<div style="background:${barColor};height:100%;width:${percentage}%;transition:width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-weight:600;min-width:100px;text-align:right;">${formatCurrency(
|
||||
point.despesas
|
||||
)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("")
|
||||
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem histórico suficiente.</td></tr>`;
|
||||
|
||||
const cardUsageRows =
|
||||
cardUsage.length > 0
|
||||
? cardUsage
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
item.name
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="2" style="padding:16px;text-align:center;color:#94a3b8;">Sem gastos com cartão neste período.</td></tr>`;
|
||||
|
||||
const boletoRows =
|
||||
boletos.length > 0
|
||||
? boletos
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;font-weight:500;">${escapeHtml(
|
||||
item.name
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
item.dueDate ? formatDate(item.dueDate) : "—"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="3" style="padding:16px;text-align:center;color:#94a3b8;">Sem boletos neste período.</td></tr>`;
|
||||
|
||||
const lancamentoRows =
|
||||
lancamentos.length > 0
|
||||
? lancamentos
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
|
||||
item.purchaseDate
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.name) || "Sem descrição"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.condition) || "—"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.paymentMethod) || "—"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.amount
|
||||
)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento registrado no período.</td></tr>`;
|
||||
|
||||
const parceladoRows =
|
||||
parcelados.length > 0
|
||||
? parcelados
|
||||
.map(
|
||||
(item) => `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;white-space:nowrap;">${formatDate(
|
||||
item.purchaseDate
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;">${
|
||||
escapeHtml(item.name) || "Sem descrição"
|
||||
}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${
|
||||
item.currentInstallment
|
||||
}/${item.installmentCount}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:600;">${formatCurrency(
|
||||
item.installmentAmount
|
||||
)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #e2e8f0;text-align:right;color:#64748b;">${formatCurrency(
|
||||
item.totalAmount
|
||||
)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="5" style="padding:16px;text-align:center;color:#94a3b8;">Nenhum lançamento parcelado neste período.</td></tr>`;
|
||||
|
||||
return `
|
||||
<div style="margin:0 auto;max-width:800px;background:#f8fafc;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter',Arial,sans-serif;color:#0f172a;line-height:1.6;">
|
||||
<!-- Preheader invisível (melhora a prévia no cliente de e-mail) -->
|
||||
<span style="display:none;visibility:hidden;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos.</span>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div style="background:linear-gradient(90deg,#dc5a3a,#ea744e);padding:28px 24px;border-radius:12px 12px 0 0;">
|
||||
<h1 style="margin:0 0 6px 0;font-size:26px;font-weight:800;letter-spacing:-0.3px;color:#ffffff;">Resumo Financeiro</h1>
|
||||
<p style="margin:0;font-size:15px;color:#ffece6;">${escapeHtml(
|
||||
periodLabel
|
||||
)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cartão principal -->
|
||||
<div style="background:#ffffff;padding:28px 24px;border-radius:0 0 12px 12px;border:1px solid #e2e8f0;border-top:none;">
|
||||
<!-- Saudação -->
|
||||
<p style="margin:0 0 24px 0;font-size:15px;color:#334155;">
|
||||
Olá <strong>${escapeHtml(
|
||||
pagadorName
|
||||
)}</strong>, segue o consolidado do mês:
|
||||
</p>
|
||||
|
||||
<!-- Totais do mês -->
|
||||
${buildSectionHeading("💰 Totais do mês")}
|
||||
<table role="presentation" style="width:100%;border-collapse:collapse;margin:0 0 28px 0;border:1px solid #f1f5f9;border-radius:10px;overflow:hidden;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;font-size:15px;color:#475569;">Total gasto</td>
|
||||
<td style="padding:16px 18px;background:#fff7f5;border-bottom:1px solid #f1f5f9;text-align:right;">
|
||||
<strong style="font-size:22px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.totalExpenses
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">💳 Cartões</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.card
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
<tr style="background:#fcfcfd;">
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">📄 Boletos</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.boleto
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 18px;font-size:14px;color:#64748b;">⚡ Pix/Débito/Dinheiro</td>
|
||||
<td style="padding:12px 18px;text-align:right;"><strong style="font-size:15px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.instant
|
||||
)}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Evolução 6 meses -->
|
||||
${buildSectionHeading("📊 Evolução das Despesas (6 meses)")}
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;margin:0 0 28px 0;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Período</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${historyRows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Gastos por cartão -->
|
||||
${buildSectionHeading("💳 Gastos com Cartões")}
|
||||
<table role="presentation" style="width:100%;border-collapse:collapse;margin:0 0 8px 0;">
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:2px solid #e2e8f0;">
|
||||
<table role="presentation" style="width:100%;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
|
||||
<td style="text-align:right;">
|
||||
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
|
||||
monthlyBreakdown.paymentSplits.card
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;margin:0 0 28px 0;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Cartão</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${cardUsageRows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Boletos -->
|
||||
${buildSectionHeading("📄 Boletos")}
|
||||
<table role="presentation" style="width:100%;border-collapse:collapse;margin:0 0 8px 0;">
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-bottom:2px solid #e2e8f0;">
|
||||
<table role="presentation" style="width:100%;border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="color:#475569;font-weight:700;font-size:15px;">Total</td>
|
||||
<td style="text-align:right;">
|
||||
<strong style="font-size:18px;color:#0f172a;">${formatCurrency(
|
||||
boletoStats.totalAmount
|
||||
)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;margin:0 0 28px 0;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Descrição</th>
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Vencimento</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${boletoRows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lançamentos -->
|
||||
${buildSectionHeading("📝 Lançamentos do Mês")}
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Data</th>
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Descrição</th>
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Condição</th>
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Pagamento</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${lancamentoRows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Lançamentos Parcelados -->
|
||||
${buildSectionHeading("💳 Lançamentos Parcelados")}
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f8fafc;">
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Data</th>
|
||||
<th style="text-align:left;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Descrição</th>
|
||||
<th style="text-align:center;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Parcela</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Valor Parcela</th>
|
||||
<th style="text-align:right;padding:12px 14px;border-bottom:1px solid #e2e8f0;font-weight:600;color:#475569;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${parceladoRows}</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Divisor suave -->
|
||||
<div style="height:1px;background:#e2e8f0;margin:28px 0;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Rodapé externo -->
|
||||
<p style="margin:16px 0 0 0;font-size:12.5px;color:#94a3b8;text-align:center;">
|
||||
Este e-mail foi enviado automaticamente pelo <strong>OpenSheets</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
`;
|
||||
};
|
||||
|
||||
export async function sendPagadorSummaryAction(
|
||||
input: z.infer<typeof inputSchema>
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
const resendApiKey = process.env.RESEND_API_KEY;
|
||||
const resendFrom =
|
||||
process.env.RESEND_FROM_EMAIL ?? "OpenSheets <onboarding@resend.dev>";
|
||||
|
||||
if (!resendApiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Serviço de e-mail não configurado (RESEND_API_KEY ausente).",
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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,
|
||||
};
|
||||
});
|
||||
|
||||
const html = buildSummaryHtml({
|
||||
pagadorName: pagadorRow.name,
|
||||
periodLabel: formatPeriodLabel(period),
|
||||
monthlyBreakdown,
|
||||
historyData,
|
||||
cardUsage,
|
||||
boletoStats,
|
||||
boletos: normalizedBoletos,
|
||||
lancamentos: normalizedLancamentos,
|
||||
parcelados: normalizedParcelados,
|
||||
});
|
||||
|
||||
await resend.emails.send({
|
||||
from: resendFrom,
|
||||
to: pagadorRow.email,
|
||||
subject: `Resumo Financeiro | ${formatPeriodLabel(period)}`,
|
||||
html,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(pagadores)
|
||||
.set({ lastMailAt: now })
|
||||
.where(
|
||||
and(eq(pagadores.id, pagadorRow.id), eq(pagadores.userId, user.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);
|
||||
}
|
||||
|
||||
// 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
53
app/(dashboard)/pagadores/[pagadorId]/data.ts
Normal file
53
app/(dashboard)/pagadores/[pagadorId]/data.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { lancamentos, pagadorShares, user as usersTable } from "@/db/schema";
|
||||
import { db } from "@/lib/db";
|
||||
import { and, desc, eq, type SQL } from "drizzle-orm";
|
||||
|
||||
export type ShareData = {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function fetchPagadorShares(
|
||||
pagadorId: string
|
||||
): Promise<ShareData[]> {
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchPagadorLancamentos(filters: SQL[]) {
|
||||
const lancamentoRows = await db.query.lancamentos.findMany({
|
||||
where: and(...filters),
|
||||
with: {
|
||||
pagador: true,
|
||||
conta: true,
|
||||
cartao: true,
|
||||
categoria: true,
|
||||
},
|
||||
orderBy: desc(lancamentos.purchaseDate),
|
||||
});
|
||||
|
||||
return lancamentoRows;
|
||||
}
|
||||
84
app/(dashboard)/pagadores/[pagadorId]/loading.tsx
Normal file
84
app/(dashboard)/pagadores/[pagadorId]/loading.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de detalhes do pagador
|
||||
* Layout: MonthPicker + Info do pagador + Tabs (Visão Geral / Lançamentos)
|
||||
*/
|
||||
export default function PagadorDetailsLoading() {
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
{/* Month Picker placeholder */}
|
||||
<div className="h-[60px] animate-pulse rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Info do Pagador (sempre visível) */}
|
||||
<div className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<Skeleton className="size-20 rounded-full bg-foreground/10" />
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Nome + Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-48 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2 border-b">
|
||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-32 rounded-t-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Conteúdo da aba Visão Geral (grid de cards) */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Card de resumo mensal */}
|
||||
<div className="rounded-2xl border p-6 space-y-4 lg:col-span-2">
|
||||
<Skeleton className="h-6 w-48 rounded-2xl bg-foreground/10" />
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-7 w-full rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outros cards */}
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
<div className="space-y-3 pt-4">
|
||||
<Skeleton className="h-5 w-full rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-3/4 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-1/2 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
384
app/(dashboard)/pagadores/[pagadorId]/page.tsx
Normal file
384
app/(dashboard)/pagadores/[pagadorId]/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
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 { 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 { LancamentosPage as LancamentosSection } from "@/components/lancamentos/page/lancamentos-page";
|
||||
import type {
|
||||
ContaCartaoFilterOption,
|
||||
LancamentoFilterOption,
|
||||
LancamentoItem,
|
||||
SelectOption,
|
||||
} from "@/components/lancamentos/types";
|
||||
import MonthPicker from "@/components/month-picker/month-picker";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { 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,
|
||||
} from "@/lib/lancamentos/page-helpers";
|
||||
import { parsePeriodParam } from "@/lib/utils/period";
|
||||
import { getPagadorAccess } from "@/lib/pagadores/access";
|
||||
import {
|
||||
fetchPagadorBoletoStats,
|
||||
fetchPagadorCardUsage,
|
||||
fetchPagadorHistory,
|
||||
fetchPagadorMonthlyBreakdown,
|
||||
} from "@/lib/pagadores/details";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchPagadorLancamentos, fetchPagadorShares } from "./data";
|
||||
|
||||
type PageSearchParams = Promise<ResolvedSearchParams>;
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ pagadorId: string }>;
|
||||
searchParams?: PageSearchParams;
|
||||
};
|
||||
|
||||
const capitalize = (value: string) =>
|
||||
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,
|
||||
};
|
||||
|
||||
const createEmptySlugMaps = (): SlugMaps => ({
|
||||
pagador: new Map(),
|
||||
categoria: new Map(),
|
||||
conta: new Map(),
|
||||
cartao: new Map(),
|
||||
});
|
||||
|
||||
type OptionSet = ReturnType<typeof buildOptionSets>;
|
||||
|
||||
export default async function Page({ params, searchParams }: PageProps) {
|
||||
const { pagadorId } = await params;
|
||||
const userId = await getUserId();
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined;
|
||||
|
||||
const access = await getPagadorAccess(userId, pagadorId);
|
||||
|
||||
if (!access) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
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 searchFilters = canEdit
|
||||
? extractLancamentoSearchFilters(resolvedSearchParams)
|
||||
: EMPTY_FILTERS;
|
||||
|
||||
let filterSources: Awaited<
|
||||
ReturnType<typeof fetchLancamentoFilterSources>
|
||||
> | null = null;
|
||||
let sluggedFilters: SluggedFilters;
|
||||
let slugMaps: SlugMaps;
|
||||
|
||||
if (canEdit) {
|
||||
filterSources = await fetchLancamentoFilterSources(dataOwnerId);
|
||||
sluggedFilters = buildSluggedFilters(filterSources);
|
||||
slugMaps = buildSlugMaps(sluggedFilters);
|
||||
} else {
|
||||
sluggedFilters = {
|
||||
pagadorFiltersRaw: [],
|
||||
categoriaFiltersRaw: [],
|
||||
contaFiltersRaw: [],
|
||||
cartaoFiltersRaw: [],
|
||||
};
|
||||
slugMaps = createEmptySlugMaps();
|
||||
}
|
||||
|
||||
const filters = buildLancamentoWhere({
|
||||
userId: dataOwnerId,
|
||||
period: selectedPeriod,
|
||||
filters: searchFilters,
|
||||
slugMaps,
|
||||
pagadorId: pagador.id,
|
||||
});
|
||||
|
||||
const sharesPromise = canEdit
|
||||
? fetchPagadorShares(pagador.id)
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [
|
||||
lancamentoRows,
|
||||
monthlyBreakdown,
|
||||
historyData,
|
||||
cardUsage,
|
||||
boletoStats,
|
||||
shareRows,
|
||||
] = 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,
|
||||
]);
|
||||
|
||||
const mappedLancamentos = mapLancamentosData(lancamentoRows);
|
||||
const lancamentosData = canEdit
|
||||
? mappedLancamentos
|
||||
: mappedLancamentos.map((item) => ({ ...item, readonly: true }));
|
||||
|
||||
const pagadorSharesData = shareRows;
|
||||
|
||||
let optionSets: OptionSet;
|
||||
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);
|
||||
}
|
||||
|
||||
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 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,
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6">
|
||||
<MonthPicker />
|
||||
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="profile">Perfil</TabsTrigger>
|
||||
<TabsTrigger value="painel">Painel</TabsTrigger>
|
||||
<TabsTrigger value="lancamentos">Lançamentos</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<section>
|
||||
<PagadorInfoCard
|
||||
pagador={pagadorData}
|
||||
selectedPeriod={selectedPeriod}
|
||||
summary={summaryPreview}
|
||||
/>
|
||||
</section>
|
||||
{canEdit && pagadorData.shareCode ? (
|
||||
<PagadorSharingCard
|
||||
pagadorId={pagador.id}
|
||||
shareCode={pagadorData.shareCode}
|
||||
shares={pagadorSharesData}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="painel" className="space-y-4">
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<PagadorMonthlySummaryCard
|
||||
periodLabel={periodLabel}
|
||||
breakdown={monthlyBreakdown}
|
||||
/>
|
||||
<PagadorHistoryCard data={historyData} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<PagadorCardUsageCard items={cardUsage} />
|
||||
<PagadorBoletoCard stats={boletoStats} />
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lancamentos">
|
||||
<section className="flex flex-col gap-4">
|
||||
<LancamentosSection
|
||||
lancamentos={lancamentosData}
|
||||
pagadorOptions={optionSets.pagadorOptions}
|
||||
splitPagadorOptions={optionSets.splitPagadorOptions}
|
||||
defaultPagadorId={pagador.id}
|
||||
contaOptions={optionSets.contaOptions}
|
||||
cartaoOptions={optionSets.cartaoOptions}
|
||||
categoriaOptions={optionSets.categoriaOptions}
|
||||
pagadorFilterOptions={pagadorFilterOptions}
|
||||
categoriaFilterOptions={optionSets.categoriaFilterOptions}
|
||||
contaCartaoFilterOptions={optionSets.contaCartaoFilterOptions}
|
||||
selectedPeriod={selectedPeriod}
|
||||
allowCreate={canEdit}
|
||||
/>
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const normalizeOptionLabel = (value: string | null | undefined, fallback: string) =>
|
||||
value?.trim().length ? value.trim() : fallback;
|
||||
|
||||
function buildReadOnlyOptionSets(
|
||||
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 contaOptionsMap = new Map<string, SelectOption>();
|
||||
const cartaoOptionsMap = new Map<string, SelectOption>();
|
||||
const categoriaOptionsMap = new Map<string, SelectOption>();
|
||||
|
||||
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 pagadorFilterOptions: LancamentoFilterOption[] = [
|
||||
{ slug: pagador.id, label: pagadorLabel },
|
||||
];
|
||||
|
||||
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,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
pagadorOptions,
|
||||
splitPagadorOptions: [],
|
||||
defaultPagadorId: pagador.id,
|
||||
contaOptions,
|
||||
cartaoOptions,
|
||||
categoriaOptions,
|
||||
pagadorFilterOptions,
|
||||
categoriaFilterOptions,
|
||||
contaCartaoFilterOptions,
|
||||
};
|
||||
}
|
||||
337
app/(dashboard)/pagadores/actions.ts
Normal file
337
app/(dashboard)/pagadores/actions.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
"use server";
|
||||
|
||||
import { pagadores, pagadorShares } 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 {
|
||||
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.",
|
||||
}),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
const createSchema = baseSchema;
|
||||
|
||||
const updateSchema = baseSchema.extend({
|
||||
id: uuidSchema("Pagador"),
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
id: uuidSchema("Pagador"),
|
||||
});
|
||||
|
||||
const shareDeleteSchema = z.object({
|
||||
shareId: uuidSchema("Compartilhamento"),
|
||||
});
|
||||
|
||||
const shareCodeJoinSchema = z.object({
|
||||
code: z
|
||||
.string({ message: "Informe o código." })
|
||||
.trim()
|
||||
.min(8, "Código inválido."),
|
||||
});
|
||||
|
||||
const shareCodeRegenerateSchema = z.object({
|
||||
pagadorId: uuidSchema("Pagador"),
|
||||
});
|
||||
|
||||
type CreateInput = z.infer<typeof createSchema>;
|
||||
type UpdateInput = z.infer<typeof updateSchema>;
|
||||
type DeleteInput = z.infer<typeof deleteSchema>;
|
||||
type ShareDeleteInput = z.infer<typeof shareDeleteSchema>;
|
||||
type ShareCodeJoinInput = z.infer<typeof shareCodeJoinSchema>;
|
||||
type ShareCodeRegenerateInput = z.infer<typeof shareCodeRegenerateSchema>;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export async function createPagadorAction(
|
||||
input: CreateInput
|
||||
): Promise<ActionResult> {
|
||||
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,
|
||||
});
|
||||
|
||||
revalidate();
|
||||
|
||||
return { success: true, message: "Pagador criado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePagadorAction(
|
||||
input: UpdateInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = updateSchema.parse(input);
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
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, user.id)));
|
||||
|
||||
revalidate();
|
||||
|
||||
return { success: true, message: "Pagador atualizado com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePagadorAction(
|
||||
input: DeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(pagadores)
|
||||
.where(and(eq(pagadores.id, data.id), eq(pagadores.userId, user.id)));
|
||||
|
||||
revalidate();
|
||||
|
||||
return { success: true, message: "Pagador removido com sucesso." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinPagadorByShareCodeAction(
|
||||
input: ShareCodeJoinInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const user = await getUser();
|
||||
const data = shareCodeJoinSchema.parse(input);
|
||||
|
||||
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.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)
|
||||
),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
revalidate();
|
||||
|
||||
return { success: true, message: "Pagador adicionado à sua lista." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePagadorShareAction(
|
||||
input: ShareDeleteInput
|
||||
): Promise<ActionResult> {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
revalidate();
|
||||
revalidatePath(`/pagadores/${existing.pagadorId}`);
|
||||
|
||||
return { success: true, message: "Compartilhamento removido." };
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function regeneratePagadorShareCodeAction(
|
||||
input: ShareCodeRegenerateInput
|
||||
): Promise<{ success: true; message: string; code: string } | ActionResult> {
|
||||
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)),
|
||||
});
|
||||
|
||||
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)));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
23
app/(dashboard)/pagadores/layout.tsx
Normal file
23
app/(dashboard)/pagadores/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import PageDescription from "@/components/page-description";
|
||||
import { RiGroupLine } from "@remixicon/react";
|
||||
|
||||
export const metadata = {
|
||||
title: "Pagadores | OpenSheets",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-6 px-6">
|
||||
<PageDescription
|
||||
icon={<RiGroupLine />}
|
||||
title="Pagadores"
|
||||
subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos."
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
app/(dashboard)/pagadores/loading.tsx
Normal file
57
app/(dashboard)/pagadores/loading.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Loading state para a página de pagadores
|
||||
* Layout: Header + Input de compartilhamento + Grid de cards
|
||||
*/
|
||||
export default function PagadoresLoading() {
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Input de código de compartilhamento */}
|
||||
<div className="rounded-2xl border p-4 space-y-3">
|
||||
<Skeleton className="h-5 w-64 rounded-2xl bg-foreground/10" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-10 w-32 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de cards de pagadores */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl border p-6 space-y-4">
|
||||
{/* Avatar + Nome + Badge */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Skeleton className="size-16 rounded-full bg-foreground/10" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-32 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full bg-foreground/10" />
|
||||
<Skeleton className="h-4 w-16 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Skeleton className="h-9 flex-1 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
<Skeleton className="h-9 w-9 rounded-2xl bg-foreground/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
86
app/(dashboard)/pagadores/page.tsx
Normal file
86
app/(dashboard)/pagadores/page.tsx
Normal file
@@ -0,0 +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";
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const userId = await getUserId();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-start gap-6">
|
||||
<PagadoresPage pagadores={pagadoresData} avatarOptions={avatarOptions} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
534
app/(landing-page)/page.tsx
Normal file
534
app/(landing-page)/page.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
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,
|
||||
RiBankCardLine,
|
||||
RiBarChartBoxLine,
|
||||
RiCalendarLine,
|
||||
RiDeviceLine,
|
||||
RiEyeOffLine,
|
||||
RiLineChartLine,
|
||||
RiLockLine,
|
||||
RiMoneyDollarCircleLine,
|
||||
RiNotificationLine,
|
||||
RiPieChartLine,
|
||||
RiShieldCheckLine,
|
||||
RiTimeLine,
|
||||
RiWalletLine,
|
||||
} from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getOptionalUserSession();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Navigation */}
|
||||
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Logo />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2 md:gap-4">
|
||||
<AnimatedThemeToggler />
|
||||
{session?.user ? (
|
||||
<Link prefetch href="/dashboard">
|
||||
<Button variant="outline" size="sm">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" size="sm">
|
||||
Entrar
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/signup">
|
||||
<Button size="sm" className="gap-2">
|
||||
Começar Grátis
|
||||
<RiArrowRightSLine size={16} />
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-16 md:py-24 lg:py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto flex max-w-5xl flex-col items-center text-center gap-6">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
<RiLineChartLine size={14} className="mr-1" />
|
||||
Controle Financeiro Inteligente
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight">
|
||||
Gerencie suas finanças
|
||||
<span className="text-primary"> com simplicidade</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl">
|
||||
Organize seus gastos, acompanhe receitas, gerencie cartões de
|
||||
crédito e tome decisões financeiras mais inteligentes. Tudo em um
|
||||
só lugar.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-4">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="gap-2 w-full sm:w-auto">
|
||||
Começar Gratuitamente
|
||||
<RiArrowRightSLine size={18} />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Fazer Login
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiShieldCheckLine size={18} className="text-primary" />
|
||||
Dados Seguros
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiEyeOffLine size={18} className="text-primary" />
|
||||
Modo Privacidade
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiDeviceLine size={18} className="text-primary" />
|
||||
100% Responsivo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 md:py-24 bg-muted/30">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="text-center mb-12">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
Funcionalidades
|
||||
</Badge>
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
|
||||
Tudo que você precisa para gerenciar suas finanças
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Ferramentas poderosas e intuitivas para controle financeiro
|
||||
completo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiWalletLine size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Lançamentos
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registre receitas e despesas com categorização
|
||||
automática e controle detalhado de pagadores e contas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiBankCardLine size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Cartões de Crédito
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie múltiplos cartões, acompanhe faturas, limites e
|
||||
nunca perca o controle dos gastos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiPieChartLine size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Categorias</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Organize suas transações em categorias personalizadas e
|
||||
visualize onde seu dinheiro está indo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiMoneyDollarCircleLine
|
||||
size={24}
|
||||
className="text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Orçamentos</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Defina limites de gastos por categoria e receba alertas
|
||||
para manter suas finanças no caminho certo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiBarChartBoxLine size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Insights</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Análise detalhada de padrões de gastos com gráficos e
|
||||
relatórios para decisões mais inteligentes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2 hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiCalendarLine size={24} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">Calendário</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visualize suas transações em calendário mensal e nunca
|
||||
perca prazos importantes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits Section */}
|
||||
<section className="py-16 md:py-24">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="grid gap-12 lg:grid-cols-2 items-center">
|
||||
<div>
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
Vantagens
|
||||
</Badge>
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-6">
|
||||
Controle financeiro descomplicado
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiShieldCheckLine size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">
|
||||
Segurança em Primeiro Lugar
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seus dados financeiros são criptografados e armazenados
|
||||
com os mais altos padrões de segurança.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiTimeLine size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Economize Tempo</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Interface intuitiva que permite registrar transações em
|
||||
segundos e acompanhar tudo de forma visual.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiNotificationLine size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">
|
||||
Alertas Inteligentes
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receba notificações sobre vencimentos, limites de
|
||||
orçamento e padrões incomuns de gastos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<RiEyeOffLine size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Modo Privacidade</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oculte valores sensíveis com um clique para visualizar
|
||||
suas finanças em qualquer lugar com discrição.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<RiLineChartLine
|
||||
size={32}
|
||||
className="text-primary shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Visualização Clara
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gráficos interativos e dashboards personalizáveis
|
||||
mostram sua situação financeira de forma clara e
|
||||
objetiva.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<RiDeviceLine
|
||||
size={32}
|
||||
className="text-primary shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Acesso em Qualquer Lugar
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Design responsivo que funciona perfeitamente em
|
||||
desktop, tablet e smartphone. Suas finanças sempre à
|
||||
mão.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<RiLockLine size={32} className="text-primary shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
Privacidade Garantida
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seus dados são seus. Sem compartilhamento com
|
||||
terceiros, sem anúncios, sem surpresas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 md:py-24 bg-muted/30">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-bold tracking-tight mb-4">
|
||||
Pronto para transformar suas finanças?
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground mb-8">
|
||||
Comece agora mesmo a organizar seu dinheiro de forma inteligente.
|
||||
É grátis e leva menos de um minuto.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/signup">
|
||||
<Button size="lg" className="gap-2 w-full sm:w-auto">
|
||||
Criar Conta Gratuita
|
||||
<RiArrowRightSLine size={18} />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/login">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Já tenho conta
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t py-12 mt-auto">
|
||||
<div className="container">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Logo />
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Gerencie suas finanças pessoais com simplicidade e segurança.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Produto</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Funcionalidades
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Preços
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Segurança
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Recursos</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Ajuda
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Tutoriais
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Legal</h3>
|
||||
<ul className="space-y-3 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Privacidade
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Termos de Uso
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Cookies
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t mt-12 pt-8 flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
© {new Date().getFullYear()} OpenSheets. Todos os direitos
|
||||
reservados.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<RiShieldCheckLine size={16} className="text-primary" />
|
||||
<span>Seus dados são protegidos e criptografados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth/config";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
39
app/api/health/route.ts
Normal file
39
app/api/health/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Health check endpoint para Docker e monitoring
|
||||
* GET /api/health
|
||||
*
|
||||
* Retorna status 200 se a aplicação está saudável
|
||||
* Verifica conexão com banco de dados
|
||||
*/
|
||||
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");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
service: "opensheets-app",
|
||||
},
|
||||
{ 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",
|
||||
timestamp: new Date().toISOString(),
|
||||
service: "opensheets-app",
|
||||
error: error instanceof Error ? error.message : "Database connection failed",
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/error.tsx
Normal file
53
app/error.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { RiErrorWarningFill } from "@remixicon/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to an error reporting service
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Empty className="max-w-md border-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon" className="bg-destructive/10 size-16">
|
||||
<RiErrorWarningFill className="size-8 text-destructive" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-2xl">Algo deu errado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ocorreu um problema inesperado. Por favor, tente novamente ou volte
|
||||
para o dashboard.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button onClick={() => reset()}>Tentar Novamente</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard">Voltar para o Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
209
app/globals.css
Normal file
209
app/globals.css
Normal file
@@ -0,0 +1,209 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--spacing-custom-height-1: 28rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(95.657% 0.00898 78.134);
|
||||
--foreground: oklch(0.1448 0 0);
|
||||
--card: oklch(98.531% 0.00274 84.298);
|
||||
--card-foreground: oklch(0.1448 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.1448 0 0);
|
||||
--primary: oklch(63.198% 0.16941 37.263);
|
||||
--primary-foreground: oklch(0.9851 0 0);
|
||||
--secondary: oklch(0.9702 0 0);
|
||||
--secondary-foreground: oklch(0.2046 0 0);
|
||||
--muted: var(--background);
|
||||
--muted-foreground: oklch(0.5555 0 0);
|
||||
--accent: oklch(0.9702 0 0);
|
||||
--accent-foreground: oklch(0.2046 0 0);
|
||||
--destructive: oklch(0.583 0.2387 28.4765);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--border: oklch(89.814% 0.00805 114.524);
|
||||
--input: oklch(70.84% 0.00279 106.916);
|
||||
--ring: oklch(76.109% 0.15119 44.68);
|
||||
--chart-1: oklch(70.734% 0.16977 153.383);
|
||||
--chart-2: oklch(62.464% 0.20395 25.32);
|
||||
--chart-3: oklch(58.831% 0.22222 298.916);
|
||||
--chart-4: oklch(0.4893 0.2202 264.0405);
|
||||
--chart-5: oklch(0.421 0.1792 266.0094);
|
||||
--sidebar: oklch(91.118% 0.01317 82.34);
|
||||
--sidebar-foreground: oklch(0.1448 0 0);
|
||||
--sidebar-primary: oklch(0.2046 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
||||
--sidebar-accent: oklch(93.199% 0.00336 67.072);
|
||||
--sidebar-accent-foreground: oklch(0.2046 0 0);
|
||||
--sidebar-border: var(--primary);
|
||||
--sidebar-ring: oklch(0.709 0 0);
|
||||
--radius: 0.8rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--month-picker: oklch(89.296% 0.0234 143.556);
|
||||
--month-picker-foreground: oklch(28% 0.035 143.556);
|
||||
--dark: oklch(27.171% 0.00927 294.877);
|
||||
--dark-foreground: oklch(91.3% 0.00281 84.324);
|
||||
--welcome-banner: var(--primary);
|
||||
--welcome-banner-foreground: oklch(98% 0.005 35.01);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(18.5% 0.008 67.284);
|
||||
--foreground: oklch(96.5% 0.002 67.284);
|
||||
--card: oklch(22.8% 0.009 67.284);
|
||||
--card-foreground: oklch(96.5% 0.002 67.284);
|
||||
--popover: oklch(24.5% 0.01 67.284);
|
||||
--popover-foreground: oklch(96.5% 0.002 67.284);
|
||||
--primary: oklch(63.198% 0.16941 37.263);
|
||||
--primary-foreground: oklch(98% 0.001 67.284);
|
||||
--secondary: oklch(26.5% 0.008 67.284);
|
||||
--secondary-foreground: oklch(96.5% 0.002 67.284);
|
||||
--muted: oklch(25.2% 0.008 67.284);
|
||||
--muted-foreground: oklch(68% 0.004 67.284);
|
||||
--accent: oklch(30.5% 0.012 67.284);
|
||||
--accent-foreground: oklch(96.5% 0.002 67.284);
|
||||
--destructive: oklch(62.5% 0.218 28.4765);
|
||||
--destructive-foreground: oklch(98% 0.001 67.284);
|
||||
--border: oklch(32.5% 0.01 114.524);
|
||||
--input: oklch(38.5% 0.012 106.916);
|
||||
--ring: oklch(68% 0.135 35.01);
|
||||
--chart-1: oklch(70.734% 0.16977 153.383);
|
||||
--chart-2: oklch(62.464% 0.20395 25.32);
|
||||
--chart-3: oklch(63.656% 0.19467 301.166);
|
||||
--chart-4: oklch(60% 0.19 264.0405);
|
||||
--chart-5: oklch(56% 0.16 266.0094);
|
||||
--sidebar: oklch(20.2% 0.009 67.484);
|
||||
--sidebar-foreground: oklch(96.5% 0.002 67.284);
|
||||
--sidebar-primary: oklch(65.5% 0.148 35.01);
|
||||
--sidebar-primary-foreground: oklch(98% 0.001 67.284);
|
||||
--sidebar-accent: oklch(28.5% 0.011 67.072);
|
||||
--sidebar-accent-foreground: oklch(96.5% 0.002 67.284);
|
||||
--sidebar-border: oklch(30% 0.01 67.484);
|
||||
--sidebar-ring: oklch(68% 0.135 35.01);
|
||||
--radius: 0.8rem;
|
||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.15);
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.2);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.25),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.25);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.3), 0 1px 2px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.35),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.35);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.4),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.4);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.45),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.45);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.5);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--month-picker: var(--card);
|
||||
--month-picker-foreground: var(--foreground);
|
||||
--dark: oklch(91.3% 0.00281 84.324);
|
||||
--dark-foreground: oklch(23.649% 0.00484 67.469);
|
||||
--welcome-banner: var(--card);
|
||||
--welcome-banner-foreground: --dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--spacing: var(--spacing);
|
||||
--color-month-picker: var(--month-picker);
|
||||
--color-month-picker-foreground: var(--month-picker-foreground);
|
||||
--color-dark: var(--dark);
|
||||
--color-dark-foreground: var(--dark-foreground);
|
||||
--color-welcome-banner: var(--welcome-banner);
|
||||
--color-welcome-banner-foreground: var(--welcome-banner-foreground);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
@apply bg-violet-400 text-foreground;
|
||||
}
|
||||
|
||||
.dark *::selection {
|
||||
@apply bg-orange-700 text-foreground;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply mx-auto px-4 lg:px-0;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
40
app/layout.tsx
Normal file
40
app/layout.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { PrivacyProvider } from "@/components/privacy-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { main_font } from "@/public/fonts/font_index";
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenSheets",
|
||||
description: "Finanças pessoais descomplicadas.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
defer
|
||||
src="https://umami.felipecoutinho.com/script.js"
|
||||
data-website-id="42f8519e-de88-467e-8969-d13a76211e43"
|
||||
></script>
|
||||
</head>
|
||||
<body
|
||||
className={`${main_font.className} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="light">
|
||||
<PrivacyProvider>
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
35
app/not-found.tsx
Normal file
35
app/not-found.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { RiFileSearchLine } from "@remixicon/react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Empty className="max-w-md border-0">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon" className="size-16">
|
||||
<RiFileSearchLine className="size-8" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-2xl">Página não encontrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
A página que você está procurando não existe ou foi movida.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard">Voltar para o Dashboard</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
components.json
Normal file
26
components.json
Normal file
@@ -0,0 +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"
|
||||
}
|
||||
}
|
||||
143
components/ajustes/delete-account-form.tsx
Normal file
143
components/ajustes/delete-account-form.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { deleteAccountAction } from "@/app/(dashboard)/ajustes/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
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 { RiAlertLine } from "@remixicon/react";
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setConfirmation("");
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (isPending) return;
|
||||
setConfirmation("");
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<RiAlertLine className="size-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="font-medium text-destructive">
|
||||
Remoção definitiva de conta
|
||||
</h3>
|
||||
<p className="text-sm text-foreground">
|
||||
Ao prosseguir, sua conta e todos os dados associados serão
|
||||
excluídos de forma irreversível.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 pl-8">
|
||||
<li>Lançamentos, anexos e notas</li>
|
||||
<li>Contas, cartões, orçamentos e categorias</li>
|
||||
<li>Pagadores (incluindo o pagador padrão)</li>
|
||||
<li>Preferências e configurações</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Deletar conta
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (isPending) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Você tem certeza?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Essa ação não pode ser desfeita. Isso irá deletar permanentemente
|
||||
sua conta e remover seus dados de nossos servidores.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation">
|
||||
Para confirmar, digite <strong>DELETAR</strong> no campo abaixo.
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="DELETAR"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCloseModal}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isPending || confirmation !== "DELETAR"}
|
||||
>
|
||||
{isPending ? "Deletando..." : "Deletar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
components/ajustes/update-email-form.tsx
Normal file
71
components/ajustes/update-email-form.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
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 { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UpdateEmailFormProps = {
|
||||
currentEmail: string;
|
||||
};
|
||||
|
||||
export function UpdateEmailForm({ currentEmail }: UpdateEmailFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [confirmEmail, setConfirmEmail] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateEmailAction({
|
||||
newEmail,
|
||||
confirmEmail,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setNewEmail("");
|
||||
setConfirmEmail("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newEmail">Novo e-mail</Label>
|
||||
<Input
|
||||
id="newEmail"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder={currentEmail}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmEmail">Confirmar novo e-mail</Label>
|
||||
<Input
|
||||
id="confirmEmail"
|
||||
type="email"
|
||||
value={confirmEmail}
|
||||
onChange={(e) => setConfirmEmail(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="repita o e-mail"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Atualizando..." : "Atualizar e-mail"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
71
components/ajustes/update-name-form.tsx
Normal file
71
components/ajustes/update-name-form.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export function UpdateNameForm({ currentName }: UpdateNameFormProps) {
|
||||
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(" ") || "";
|
||||
|
||||
const [firstName, setFirstName] = useState(initialFirstName);
|
||||
const [lastName, setLastName] = useState(initialLastName);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updateNameAction({
|
||||
firstName,
|
||||
lastName,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Primeiro nome</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">Sobrenome</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Atualizando..." : "Atualizar nome"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
98
components/ajustes/update-password-form.tsx
Normal file
98
components/ajustes/update-password-form.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
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 { RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function UpdatePasswordForm() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await updatePasswordAction({
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">Nova senha</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Mínimo de 6 caracteres"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmar nova senha</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isPending}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<RiEyeOffLine size={20} />
|
||||
) : (
|
||||
<RiEyeLine size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Atualizando..." : "Atualizar senha"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
122
components/animated-theme-toggler.tsx
Normal file
122
components/animated-theme-toggler.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
className,
|
||||
duration = 400,
|
||||
...props
|
||||
}: AnimatedThemeTogglerProps) => {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
data-state={isDark ? "dark" : "light"}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative text-muted-foreground transition-all duration-200",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
|
||||
>
|
||||
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
|
||||
</span>
|
||||
{isDark ? (
|
||||
<RiSunLine
|
||||
className="size-4 transition-transform duration-200"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<RiMoonClearLine
|
||||
className="size-4 transition-transform duration-200"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
{isDark ? "Tema claro" : "Tema escuro"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
139
components/anotacoes/note-card.tsx
Normal file
139
components/anotacoes/note-card.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { RiDeleteBin5Line, RiEyeLine, RiPencilLine } from "@remixicon/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Note } from "./types";
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "medium",
|
||||
});
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note;
|
||||
onEdit?: (note: Note) => void;
|
||||
onDetails?: (note: Note) => void;
|
||||
onRemove?: (note: Note) => void;
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onEdit, onDetails, onRemove }: NoteCardProps) {
|
||||
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]);
|
||||
|
||||
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: <RiPencilLine className="size-4" aria-hidden />,
|
||||
onClick: onEdit,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "detalhes",
|
||||
icon: <RiEyeLine className="size-4" aria-hidden />,
|
||||
onClick: onDetails,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
label: "remover",
|
||||
icon: <RiDeleteBin5Line className="size-4" aria-hidden />,
|
||||
onClick: onRemove,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
].filter((action) => typeof action.onClick === "function");
|
||||
|
||||
return (
|
||||
<Card className="h-[300px] w-[440px] gap-0">
|
||||
<CardContent className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-lg font-semibold leading-tight text-foreground wrap-break-word">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
{isTask && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{completedCount}/{totalCount} concluídas
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
|
||||
{formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isTask ? (
|
||||
<div className="flex-1 overflow-auto space-y-2">
|
||||
{tasks.slice(0, 4).map((task) => (
|
||||
<div key={task.id} className="flex items-start gap-2 text-sm">
|
||||
<div
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
|
||||
task.completed
|
||||
? "bg-green-600 border-green-600"
|
||||
: "border-input"
|
||||
}`}
|
||||
>
|
||||
{task.completed && (
|
||||
<CheckIcon className="h-3 w-3 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`leading-relaxed ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tasks.length > 4 && (
|
||||
<p className="text-xs text-muted-foreground pl-5 py-1">
|
||||
+{tasks.length - 4}{" "}
|
||||
{tasks.length - 4 === 1 ? "tarefa" : "tarefas"}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex-1 overflow-auto whitespace-pre-line text-sm text-muted-foreground wrap-break-word leading-relaxed">
|
||||
{note.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{actions.length > 0 ? (
|
||||
<CardFooter className="flex flex-wrap gap-3 px-6 pt-3 text-sm">
|
||||
{actions.map(({ label, icon, onClick, variant }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => onClick?.(note)}
|
||||
className={`flex items-center gap-1 font-medium transition-opacity hover:opacity-80 ${
|
||||
variant === "destructive" ? "text-destructive" : "text-primary"
|
||||
}`}
|
||||
aria-label={`${label} anotação`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
components/anotacoes/note-details-dialog.tsx
Normal file
118
components/anotacoes/note-details-dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { Note } from "./types";
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat("pt-BR", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
interface NoteDetailsDialogProps {
|
||||
note: Note | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function NoteDetailsDialog({
|
||||
note,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NoteDetailsDialogProps) {
|
||||
const { formattedDate, displayTitle } = useMemo(() => {
|
||||
if (!note) {
|
||||
return { formattedDate: "", displayTitle: "" };
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{displayTitle}
|
||||
{isTask && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{completedCount}/{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{formattedDate}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isTask ? (
|
||||
<div className="max-h-[320px] overflow-auto space-y-3">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div
|
||||
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border ${
|
||||
task.completed
|
||||
? "bg-green-600 border-green-600"
|
||||
: "border-input"
|
||||
}`}
|
||||
>
|
||||
{task.completed && (
|
||||
<CheckIcon className="h-4 w-4 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[320px] overflow-auto whitespace-pre-line wrap-break-word text-sm text-foreground">
|
||||
{note.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Fechar
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
470
components/anotacoes/note-dialog.tsx
Normal file
470
components/anotacoes/note-dialog.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
} 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 { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
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;
|
||||
}
|
||||
|
||||
const MAX_TITLE = 30;
|
||||
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 ?? [],
|
||||
});
|
||||
|
||||
const generateTaskId = () => {
|
||||
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
};
|
||||
|
||||
export function NoteDialog({
|
||||
mode,
|
||||
trigger,
|
||||
note,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: NoteDialogProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [newTaskText, setNewTaskText] = useState("");
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const descRef = useRef<HTMLTextAreaElement>(null);
|
||||
const newTaskRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled state hook for dialog open state
|
||||
const [dialogOpen, setDialogOpen] = useControlledState(
|
||||
open,
|
||||
false,
|
||||
onOpenChange
|
||||
);
|
||||
|
||||
const initialState = buildInitialValues(note);
|
||||
|
||||
// Use form state hook for form management
|
||||
const { formState, updateField, setFormState } =
|
||||
useFormState<NoteFormValues>(initialState);
|
||||
|
||||
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 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 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 disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
|
||||
|
||||
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 handleAddTask = useCallback(() => {
|
||||
const text = normalize(newTaskText);
|
||||
if (!text) return;
|
||||
|
||||
const newTask: Task = {
|
||||
id: generateTaskId(),
|
||||
text,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
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 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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
|
||||
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 (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;
|
||||
}
|
||||
|
||||
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,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>
|
||||
{trigger ? <DialogTrigger asChild>{trigger}</DialogTrigger> : null}
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
noValidate
|
||||
>
|
||||
{/* Seletor de Tipo - apenas no modo de criação */}
|
||||
{mode === "create" && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Tipo de anotação
|
||||
</label>
|
||||
<RadioGroup
|
||||
value={formState.type}
|
||||
onValueChange={(value) =>
|
||||
updateField("type", value as "nota" | "tarefa")
|
||||
}
|
||||
disabled={isPending}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="nota" id="tipo-nota" />
|
||||
<label
|
||||
htmlFor="tipo-nota"
|
||||
className="text-sm cursor-pointer select-none"
|
||||
>
|
||||
Nota
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="tarefa" id="tipo-tarefa" />
|
||||
<label
|
||||
htmlFor="tipo-tarefa"
|
||||
className="text-sm cursor-pointer select-none"
|
||||
>
|
||||
Tarefas
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Título */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="note-title"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Título
|
||||
</label>
|
||||
<Input
|
||||
id="note-title"
|
||||
ref={titleRef}
|
||||
value={formState.title}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<p
|
||||
id="note-title-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
Até {MAX_TITLE} caracteres. Restantes:{" "}
|
||||
{Math.max(0, MAX_TITLE - titleCount)}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conteúdo - apenas para Notas */}
|
||||
{isNote && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="note-description"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Conteúdo
|
||||
</label>
|
||||
<Textarea
|
||||
id="note-description"
|
||||
className="field-sizing-fixed"
|
||||
ref={descRef}
|
||||
value={formState.description}
|
||||
onChange={(e) => updateField("description", e.target.value)}
|
||||
placeholder="Detalhe sua anotação..."
|
||||
rows={6}
|
||||
maxLength={MAX_DESC}
|
||||
disabled={isPending}
|
||||
aria-describedby="note-desc-help"
|
||||
required
|
||||
/>
|
||||
<p
|
||||
id="note-desc-help"
|
||||
className="text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
Até {MAX_DESC} caracteres. Restantes:{" "}
|
||||
{Math.max(0, MAX_DESC - descCount)}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de Tarefas - apenas para Tarefas */}
|
||||
{!isNote && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="new-task-input"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
Adicionar tarefa
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="new-task-input"
|
||||
ref={newTaskRef}
|
||||
value={newTaskText}
|
||||
onChange={(e) => setNewTaskText(e.target.value)}
|
||||
placeholder="Ex.: Comprar ingredientes para o jantar"
|
||||
disabled={isPending}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddTask();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddTask}
|
||||
disabled={isPending || !normalize(newTaskText)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pressione Enter ou clique no botão + para adicionar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Lista de tarefas existentes */}
|
||||
{formState.tasks && formState.tasks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Tarefas ({formState.tasks.length})
|
||||
</label>
|
||||
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
|
||||
{formState.tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={task.completed}
|
||||
onCheckedChange={() => handleToggleTask(task.id)}
|
||||
disabled={isPending}
|
||||
aria-label={`Marcar tarefa "${task.text}" como ${
|
||||
task.completed ? "não concluída" : "concluída"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`flex-1 text-sm wrap-break-word ${
|
||||
task.completed
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTask(task.id)}
|
||||
disabled={isPending}
|
||||
className="h-8 w-8 p-0 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Remover tarefa "${task.text}"`}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={disableSubmit}>
|
||||
{isPending ? "Salvando..." : submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
165
components/anotacoes/notes-page.tsx
Normal file
165
components/anotacoes/notes-page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { deleteNoteAction } from "@/app/(dashboard)/anotacoes/actions";
|
||||
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiAddCircleLine, RiFileListLine } from "@remixicon/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "../ui/card";
|
||||
import { NoteCard } from "./note-card";
|
||||
import { NoteDetailsDialog } from "./note-details-dialog";
|
||||
import { NoteDialog } from "./note-dialog";
|
||||
import type { Note } from "./types";
|
||||
|
||||
interface NotesPageProps {
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
export function NotesPage({ notes }: NotesPageProps) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [noteDetails, setNoteDetails] = useState<Note | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [noteToRemove, setNoteToRemove] = useState<Note | null>(null);
|
||||
|
||||
const sortedNotes = useMemo(
|
||||
() =>
|
||||
[...notes].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
),
|
||||
[notes]
|
||||
);
|
||||
|
||||
const handleCreateOpenChange = useCallback((open: boolean) => {
|
||||
setCreateOpen(open);
|
||||
}, []);
|
||||
|
||||
const handleEditOpenChange = useCallback((open: boolean) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setNoteToEdit(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDetailsOpenChange = useCallback((open: boolean) => {
|
||||
setDetailsOpen(open);
|
||||
if (!open) {
|
||||
setNoteDetails(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveOpenChange = useCallback((open: boolean) => {
|
||||
setRemoveOpen(open);
|
||||
if (!open) {
|
||||
setNoteToRemove(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEditRequest = useCallback((note: Note) => {
|
||||
setNoteToEdit(note);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDetailsRequest = useCallback((note: Note) => {
|
||||
setNoteDetails(note);
|
||||
setDetailsOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveRequest = useCallback((note: Note) => {
|
||||
setNoteToRemove(note);
|
||||
setRemoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRemoveConfirm = useCallback(async () => {
|
||||
if (!noteToRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteNoteAction({ id: noteToRemove.id });
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(result.error);
|
||||
throw new Error(result.error);
|
||||
}, [noteToRemove]);
|
||||
|
||||
const removeTitle = noteToRemove
|
||||
? noteToRemove.title.trim().length
|
||||
? `Remover anotação "${noteToRemove.title}"?`
|
||||
: "Remover anotação?"
|
||||
: "Remover anotação?";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="flex justify-start">
|
||||
<NoteDialog
|
||||
mode="create"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
trigger={
|
||||
<Button>
|
||||
<RiAddCircleLine className="size-4" />
|
||||
Nova anotação
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sortedNotes.length === 0 ? (
|
||||
<Card className="flex min-h-[50vh] w-full items-center justify-center py-12">
|
||||
<EmptyState
|
||||
media={<RiFileListLine className="size-6 text-primary" />}
|
||||
title="Nenhuma anotação registrada"
|
||||
description="Crie anotações personalizadas para acompanhar lembretes, decisões ou observações financeiras importantes."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{sortedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={handleEditRequest}
|
||||
onDetails={handleDetailsRequest}
|
||||
onRemove={handleRemoveRequest}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NoteDialog
|
||||
mode="update"
|
||||
note={noteToEdit ?? undefined}
|
||||
open={editOpen}
|
||||
onOpenChange={handleEditOpenChange}
|
||||
/>
|
||||
|
||||
<NoteDetailsDialog
|
||||
note={noteDetails}
|
||||
open={detailsOpen}
|
||||
onOpenChange={handleDetailsOpenChange}
|
||||
/>
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={removeOpen}
|
||||
onOpenChange={handleRemoveOpenChange}
|
||||
title={removeTitle}
|
||||
description="Essa ação não pode ser desfeita."
|
||||
confirmLabel="Remover"
|
||||
confirmVariant="destructive"
|
||||
pendingLabel="Removendo..."
|
||||
onConfirm={handleRemoveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
components/anotacoes/types.ts
Normal file
23
components/anotacoes/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type NoteType = "nota" | "tarefa";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: NoteType;
|
||||
tasks?: Task[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NoteFormValues {
|
||||
title: string;
|
||||
description: string;
|
||||
type: NoteType;
|
||||
tasks?: Task[];
|
||||
}
|
||||
17
components/auth/auth-error-alert.tsx
Normal file
17
components/auth/auth-error-alert.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { RiTerminalLine } from "@remixicon/react";
|
||||
|
||||
interface AuthErrorAlertProps {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function AuthErrorAlert({ error }: AuthErrorAlertProps) {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Alert className="mt-2 border border-red-500" variant="destructive">
|
||||
<RiTerminalLine className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
17
components/auth/auth-footer.tsx
Normal file
17
components/auth/auth-footer.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FieldDescription } from "@/components/ui/field";
|
||||
|
||||
export function AuthFooter() {
|
||||
return (
|
||||
<FieldDescription className="px-6 text-center">
|
||||
Ao continuar, você concorda com nossos{" "}
|
||||
<a href="/terms" className="underline underline-offset-4">
|
||||
Termos de Serviço
|
||||
</a>{" "}
|
||||
e{" "}
|
||||
<a href="/privacy" className="underline underline-offset-4">
|
||||
Política de Privacidade
|
||||
</a>
|
||||
.
|
||||
</FieldDescription>
|
||||
);
|
||||
}
|
||||
17
components/auth/auth-header.tsx
Normal file
17
components/auth/auth-header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5")}>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-card-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
components/auth/auth-sidebar.tsx
Normal file
34
components/auth/auth-sidebar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import MagnetLines from "../magnet-lines";
|
||||
|
||||
function AuthSidebar() {
|
||||
return (
|
||||
<div className="relative hidden flex-col overflow-hidden bg-welcome-banner text-welcome-banner-foreground md:flex">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
|
||||
<MagnetLines
|
||||
rows={10}
|
||||
columns={16}
|
||||
containerSize="120%"
|
||||
lineColor="currentColor"
|
||||
lineWidth="0.35vmin"
|
||||
lineHeight="5vmin"
|
||||
baseAngle={-4}
|
||||
className="text-welcome-banner-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col justify-between p-8">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-3xl font-semibold leading-tight">
|
||||
Controle suas finanças com clareza e foco diário.
|
||||
</h2>
|
||||
<p className="text-sm opacity-90">
|
||||
Centralize despesas, organize cartões e acompanhe metas mensais em
|
||||
um painel inteligente feito para o seu dia a dia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthSidebar;
|
||||
54
components/auth/google-auth-button.tsx
Normal file
54
components/auth/google-auth-button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
|
||||
interface GoogleAuthButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function GoogleAuthButton({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
text = "Continuar com Google",
|
||||
}: GoogleAuthButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span>{text}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
187
components/auth/login-form.tsx
Normal file
187
components/auth/login-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "../logo";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import AuthSidebar from "./auth-sidebar";
|
||||
import { GoogleAuthButton } from "./google-auth-button";
|
||||
|
||||
type DivProps = React.ComponentProps<"div">;
|
||||
|
||||
export function LoginForm({ className, ...props }: DivProps) {
|
||||
const router = useRouter();
|
||||
const isGoogleAvailable = googleSignInAvailable;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
const [loadingGoogle, setLoadingGoogle] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/dashboard",
|
||||
rememberMe: false,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
setError("");
|
||||
setLoadingEmail(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingEmail(false);
|
||||
toast.success("Login realizado com sucesso!");
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
if (!isGoogleAvailable) {
|
||||
setError("Login com Google não está disponível no momento.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ativa loading antes de iniciar o fluxo OAuth
|
||||
setError("");
|
||||
setLoadingGoogle(true);
|
||||
|
||||
// OAuth redirect - o loading permanece até a página ser redirecionada
|
||||
await authClient.signIn.social(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
// Só desativa loading se houver erro
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Logo className="mb-2" />
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-6 md:p-8"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<AuthHeader
|
||||
title="Entrar no OpenSheets"
|
||||
description="Entre com a sua conta"
|
||||
/>
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<div className="flex items-center">
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Digite sua senha"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loadingEmail || loadingGoogle}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Entrar"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
|
||||
Ou continue com
|
||||
</FieldSeparator>
|
||||
|
||||
<Field>
|
||||
<GoogleAuthButton
|
||||
onClick={handleGoogle}
|
||||
loading={loadingGoogle}
|
||||
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
|
||||
text="Entrar com Google"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
Não tem uma conta?{" "}
|
||||
<a href="/signup" className="underline underline-offset-4">
|
||||
Inscreva-se
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<AuthSidebar />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <AuthFooter /> */}
|
||||
<FieldDescription className="text-center">
|
||||
<a href="/" className="underline underline-offset-4">
|
||||
Voltar para o site
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
components/auth/logout-button.tsx
Normal file
56
components/auth/logout-button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleLogOut() {
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
},
|
||||
onRequest: (_ctx) => {
|
||||
setLoading(true);
|
||||
},
|
||||
onResponse: (_ctx) => {
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
aria-busy={loading}
|
||||
data-loading={loading}
|
||||
onClick={handleLogOut}
|
||||
disabled={loading}
|
||||
className="text-destructive transition-all duration-200 border hover:text-destructive focus-visible:ring-destructive/30 data-[loading=true]:opacity-90"
|
||||
>
|
||||
{loading && <Spinner className="size-3.5 text-destructive" />}
|
||||
<span aria-live="polite">{loading ? "Saindo" : "Sair"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Encerrar sessão
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
199
components/auth/signup-form.tsx
Normal file
199
components/auth/signup-form.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient, googleSignInAvailable } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiLoader4Line } from "@remixicon/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "../logo";
|
||||
import { AuthErrorAlert } from "./auth-error-alert";
|
||||
import { AuthHeader } from "./auth-header";
|
||||
import AuthSidebar from "./auth-sidebar";
|
||||
import { GoogleAuthButton } from "./google-auth-button";
|
||||
|
||||
type DivProps = React.ComponentProps<"div">;
|
||||
|
||||
export function SignupForm({ className, ...props }: DivProps) {
|
||||
const router = useRouter();
|
||||
const isGoogleAvailable = googleSignInAvailable;
|
||||
|
||||
const [fullname, setFullname] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [loadingEmail, setLoadingEmail] = useState(false);
|
||||
const [loadingGoogle, setLoadingGoogle] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name: fullname,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
setError("");
|
||||
setLoadingEmail(true);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLoadingEmail(false);
|
||||
toast.success("Conta criada com sucesso!");
|
||||
router.replace("/dashboard");
|
||||
},
|
||||
onError: (ctx) => {
|
||||
setError(ctx.error.message);
|
||||
setLoadingEmail(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
if (!isGoogleAvailable) {
|
||||
setError("Login com Google não está disponível no momento.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ativa loading antes de iniciar o fluxo OAuth
|
||||
setError("");
|
||||
setLoadingGoogle(true);
|
||||
|
||||
// OAuth redirect - o loading permanece até a página ser redirecionada
|
||||
await authClient.signIn.social(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
onError: (ctx) => {
|
||||
// Só desativa loading se houver erro
|
||||
setError(ctx.error.message);
|
||||
setLoadingGoogle(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Logo className="mb-2" />
|
||||
<Card className="overflow-hidden p-0">
|
||||
<CardContent className="grid gap-0 p-0 md:grid-cols-[1.05fr_0.95fr]">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-6 md:p-8"
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<FieldGroup className="gap-4">
|
||||
<AuthHeader
|
||||
title="Criar sua conta"
|
||||
description="Comece com sua nova conta"
|
||||
/>
|
||||
|
||||
<AuthErrorAlert error={error} />
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Nome completo</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Digite seu nome"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">E-mail</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">Senha</FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="Crie uma senha forte"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loadingEmail || loadingGoogle}
|
||||
className="w-full"
|
||||
>
|
||||
{loadingEmail ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Criar conta"
|
||||
)}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<FieldSeparator className="my-2 *:data-[slot=field-separator-content]:bg-card">
|
||||
Ou continue com
|
||||
</FieldSeparator>
|
||||
|
||||
<Field>
|
||||
<GoogleAuthButton
|
||||
onClick={handleGoogle}
|
||||
loading={loadingGoogle}
|
||||
disabled={loadingEmail || loadingGoogle || !isGoogleAvailable}
|
||||
text="Continuar com Google"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<FieldDescription className="text-center">
|
||||
Já tem uma conta?{" "}
|
||||
<a href="/login" className="underline underline-offset-4">
|
||||
Entrar
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<AuthSidebar />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* <AuthFooter /> */}
|
||||
<FieldDescription className="text-center">
|
||||
<a href="/" className="underline underline-offset-4">
|
||||
Voltar para o site
|
||||
</a>
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
components/calculadora/calculator-dialog.tsx
Normal file
109
components/calculadora/calculator-dialog.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Calculator from "@/components/calculadora/calculator";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils/ui";
|
||||
import { RiCalculatorFill, RiCalculatorLine } from "@remixicon/react";
|
||||
import * as React from "react";
|
||||
|
||||
type Variant = React.ComponentProps<typeof Button>["variant"];
|
||||
type Size = React.ComponentProps<typeof Button>["size"];
|
||||
|
||||
type CalculatorDialogButtonProps = {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
withTooltip?: boolean;
|
||||
};
|
||||
|
||||
export function CalculatorDialogButton({
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
className,
|
||||
children,
|
||||
withTooltip = false,
|
||||
}: CalculatorDialogButtonProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// Se withTooltip for true, usa o estilo do header
|
||||
if (withTooltip) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Calculadora"
|
||||
aria-expanded={open}
|
||||
data-state={open ? "open" : "closed"}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"group relative text-muted-foreground transition-all duration-200",
|
||||
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<RiCalculatorLine
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-200",
|
||||
open ? "scale-90" : "scale-100"
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Calculadora</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8}>
|
||||
Calculadora
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className="p-4 sm:max-w-sm">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<RiCalculatorLine className="h-5 w-5" />
|
||||
Calculadora
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Calculator />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Estilo padrão para outros usos
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={variant} size={size} className={cn(className)}>
|
||||
{children ?? (
|
||||
<RiCalculatorFill className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="p-4 sm:max-w-sm">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<RiCalculatorLine className="h-5 w-5" />
|
||||
Calculadora
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Calculator />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
49
components/calculadora/calculator-display.tsx
Normal file
49
components/calculadora/calculator-display.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
|
||||
export type CalculatorDisplayProps = {
|
||||
history: string | null;
|
||||
expression: string;
|
||||
resultText: string | null;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
};
|
||||
|
||||
export function CalculatorDisplay({
|
||||
history,
|
||||
expression,
|
||||
resultText,
|
||||
copied,
|
||||
onCopy,
|
||||
}: CalculatorDisplayProps) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-muted px-4 py-5 text-right">
|
||||
{history && (
|
||||
<div className="text-sm text-muted-foreground">{history}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="text-right text-3xl font-semibold tracking-tight tabular-nums">
|
||||
{expression}
|
||||
</div>
|
||||
{resultText && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onCopy}
|
||||
className="h-6 w-6 shrink-0 rounded-full p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
{copied ? "Resultado copiado" : "Copiar resultado"}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user