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