commit ea0b8618e0d7909e3ad8bcc68fe58bf9d695a303 Author: Felipe Coutinho Date: Sat Nov 15 15:49:36 2025 -0300 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7b0aad3 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48ed0f7 --- /dev/null +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f9a65d --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1371262 --- /dev/null +++ b/.vscode/settings.json @@ -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" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afa30de --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..28d20ef --- /dev/null +++ b/README.md @@ -0,0 +1,1030 @@ +# OpenSheets + +> Uma aplicação moderna e completa construída com **Next.js 16**, **Better Auth**, **Drizzle ORM**, **PostgreSQL** e **shadcn/ui**. + +[![Next.js](https://img.shields.io/badge/Next.js-16-black?style=flat-square&logo=next.js)](https://nextjs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) +[![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=flat-square&logo=docker)](https://www.docker.com/) + +--- + +## 📖 Índice + +- [Sobre o Projeto](#-sobre-o-projeto) +- [Features](#-features) +- [Tech Stack](#-tech-stack) +- [Início Rápido](#-início-rápido) + - [Opção 1: Desenvolvimento Local (Recomendado para Devs)](#opção-1-desenvolvimento-local-recomendado-para-devs) + - [Opção 2: Docker Completo (Usuários Finais)](#opção-2-docker-completo-usuários-finais) + - [Opção 3: Docker + Banco Remoto](#opção-3-docker--banco-remoto) +- [Scripts Disponíveis](#-scripts-disponíveis) +- [Docker - Guia Detalhado](#-docker---guia-detalhado) +- [Configuração de Variáveis de Ambiente](#-configuração-de-variáveis-de-ambiente) +- [Banco de Dados](#-banco-de-dados) +- [Arquitetura](#-arquitetura) +- [Troubleshooting](#-troubleshooting) +- [Contribuindo](#-contribuindo) + +--- + +## 🎯 Sobre o Projeto + +**OpenSheets** é uma aplicação full-stack moderna projetada para controle de finanças pessoais. Construída com as melhores práticas de desenvolvimento e ferramentas de ponta, oferece uma base sólida e escalável para gestão financeira completa. + +### Por que usar o OpenSheets? + +- ✅ **Pronto para Produção** - Docker, health checks, migrations automáticas +- ✅ **TypeScript First** - Type safety em toda a aplicação +- ✅ **Autenticação Completa** - Better Auth com OAuth, email magic links +- ✅ **ORM Moderno** - Drizzle com Drizzle Studio integrado +- ✅ **UI Components** - shadcn/ui com design system completo +- ✅ **Developer Experience** - Hot reload, Turbopack, ESLint configurado + +--- + +## ✨ Features + +### 🔐 Autenticação + +- Better Auth integrado +- OAuth (Google, GitHub) +- Email magic links +- Session management +- Protected routes via middleware + +### 🗄️ Banco de Dados + +- PostgreSQL 18 (última versão estável) +- Drizzle ORM com TypeScript +- Migrations automáticas +- Drizzle Studio (UI visual para DB) +- Suporte para banco local (Docker) ou remoto (Supabase, Neon, etc) + +### 🎨 Interface + +- shadcn/ui components +- Tailwind CSS v4 +- Dark mode suportado +- Animações com Framer Motion + +### 🐳 Docker + +- Multi-stage build otimizado +- Health checks para app e banco +- Volumes persistentes +- Network isolada +- Scripts npm facilitados + +### 🧪 Desenvolvimento + +- Next.js 16 com App Router +- Turbopack (fast refresh) +- TypeScript 5.9 +- ESLint + Prettier +- React 19 + +--- + +## 🛠️ Tech Stack + +### Frontend + +- **Framework:** Next.js 16 (App Router) +- **Linguagem:** TypeScript 5.9 +- **UI Library:** React 19 +- **Styling:** Tailwind CSS v4 +- **Components:** shadcn/ui (Radix UI) +- **Icons:** Lucide React, Remixicon +- **Animations:** Framer Motion + +### Backend + +- **Runtime:** Node.js 22 +- **Database:** PostgreSQL 18 +- **ORM:** Drizzle ORM +- **Auth:** Better Auth +- **Email:** Resend + +### DevOps + +- **Containerization:** Docker + Docker Compose +- **Package Manager:** pnpm +- **Build Tool:** Turbopack + +### AI Integration (Opcional) + +- Anthropic (Claude) +- OpenAI (GPT) +- Google Gemini +- OpenRouter + +--- + +## 🚀 Início Rápido + +Escolha a opção que melhor se adequa ao seu caso: + +| Cenário | Quando usar | Comando principal | +| ----------- | ----------------------------------------- | -------------------------------------- | +| **Opção 1** | Você vai **desenvolver** e alterar código | `docker compose up db -d` + `pnpm dev` | +| **Opção 2** | Você só quer **usar** a aplicação | `pnpm docker:up` | +| **Opção 3** | Você já tem um **banco remoto** | `docker compose up app --build` | + +--- + +### Opção 1: Desenvolvimento Local (Recomendado para Devs) + +Esta é a **melhor opção para desenvolvedores** que vão modificar o código. + +#### Pré-requisitos + +- Node.js 22+ instalado +- pnpm instalado (ou npm/yarn) +- Docker e Docker Compose instalados + +#### Passo a Passo + +1. **Clone o repositório** + + ```bash + git clone https://github.com/felipegcoutinho/opensheets.git + cd opensheets + ``` + +2. **Instale as dependências** + + ```bash + pnpm install + ``` + +3. **Configure as variáveis de ambiente** + + ```bash + cp .env.example .env + ``` + + Edite o `.env` e configure: + + ```env + # Banco de dados (usando Docker) + DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db + DB_PROVIDER=local + + # Better Auth (gere com: openssl rand -base64 32) + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +4. **Suba apenas o PostgreSQL em Docker** + + ```bash + docker compose up db -d + ``` + + Isso sobe **apenas o banco de dados** em container. A aplicação roda localmente. + +5. **Execute as migrations** + + ```bash + pnpm db:push + ``` + +6. **Inicie o servidor de desenvolvimento** + + ```bash + pnpm dev + ``` + +7. **Acesse a aplicação** + ``` + http://localhost:3000 + ``` + +#### Por que esta opção? + +- ✅ **Hot reload perfeito** - Mudanças no código refletem instantaneamente +- ✅ **Debugger funciona** - Use breakpoints normalmente +- ✅ **Menos recursos** - Só o banco roda em Docker +- ✅ **Drizzle Studio** - Acesse com `pnpm db:studio` +- ✅ **Melhor DX** - Developer Experience otimizada + +--- + +### Opção 2: Docker Completo (Usuários Finais) + +Ideal para quem quer apenas **usar a aplicação** sem mexer no código. + +#### Pré-requisitos + +- Docker e Docker Compose instalados + +#### Passo a Passo + +1. **Clone o repositório** + + ```bash + git clone https://github.com/felipegcoutinho/opensheets.git + cd opensheets + ``` + +2. **Configure as variáveis de ambiente** + + ```bash + cp .env.example .env + ``` + + Edite o `.env`: + + ```env + # Use o host "db" (nome do serviço Docker) + DATABASE_URL=postgresql://opensheets:opensheets_dev_password@db:5432/opensheets_db + DB_PROVIDER=local + + # Better Auth + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +3. **Suba tudo em Docker** + + ```bash + pnpm docker:up + # ou: docker compose up --build + ``` + + Isso sobe **aplicação + banco de dados** em containers. + +4. **Acesse a aplicação** + + ``` + http://localhost:3000 + ``` + +5. **Para parar** + ```bash + pnpm docker:down + # ou: docker compose down + ``` + +#### Dicas + +- Use `pnpm docker:up:detached` para rodar em background +- Veja logs com `pnpm docker:logs` +- Reinicie com `pnpm docker:restart` + +--- + +### Opção 3: Docker + Banco Remoto + +Se você já tem PostgreSQL no **Supabase**, **Neon**, **Railway**, etc. + +#### Passo a Passo + +1. **Configure o `.env` com banco remoto** + + ```env + DATABASE_URL=postgresql://user:password@host.region.provider.com:5432/database?sslmode=require + DB_PROVIDER=remote + + BETTER_AUTH_SECRET=seu-secret-aqui + BETTER_AUTH_URL=http://localhost:3000 + ``` + +2. **Suba apenas a aplicação** + + ```bash + docker compose up app --build + ``` + +3. **Acesse a aplicação** + ``` + http://localhost:3000 + ``` + +--- + +## 📜 Scripts Disponíveis + +### Desenvolvimento + +```bash +# Servidor de desenvolvimento (com Turbopack) +pnpm dev + +# Build de produção +pnpm build + +# Servidor de produção +pnpm start + +# Linter +pnpm lint +``` + +### Banco de Dados (Drizzle) + +```bash +# Gerar migrations a partir do schema +pnpm db:generate + +# Executar migrations +pnpm db:migrate + +# Push schema direto para o banco (dev only) +pnpm db:push + +# Abrir Drizzle Studio (UI visual do banco) +pnpm db:studio +``` + +### Docker + +```bash +# Subir todos os containers (app + banco) +pnpm docker:up + +# Subir em background (detached mode) +pnpm docker:up:detached + +# Parar todos os containers +pnpm docker:down + +# Parar e REMOVER volumes (⚠️ apaga dados do banco!) +pnpm docker:down:volumes + +# Ver logs em tempo real +pnpm docker:logs + +# Logs apenas da aplicação +pnpm docker:logs:app + +# Logs apenas do banco de dados +pnpm docker:logs:db + +# Reiniciar containers +pnpm docker:restart + +# Rebuild completo (força reconstrução) +pnpm docker:rebuild +``` + +### Utilitários + +```bash +# Setup automático de variáveis de ambiente +pnpm env:setup +``` + +--- + +## 🐳 Docker - Guia Detalhado + +### Arquitetura Docker + +``` +┌─────────────────────────────────────────────────┐ +│ docker-compose.yml │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ app │ │ db │ │ +│ │ (Next.js 16) │◄─────┤ (PostgreSQL 18)│ │ +│ │ Port: 3000 │ │ Port: 5432 │ │ +│ │ Node.js 22 │ │ Alpine Linux │ │ +│ └──────────────────┘ └─────────────────┘ │ +│ │ +│ Network: opensheets_network (bridge) │ +│ Volume: opensheets_postgres_data (persistent) │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +### Multi-Stage Build + +O `Dockerfile` usa **3 stages** para otimização: + +1. **deps** - Instala dependências +2. **builder** - Builda a aplicação (Next.js standalone) +3. **runner** - Imagem final mínima (apenas produção) + +**Benefícios:** + +- Imagem final **muito menor** (~200MB vs ~1GB) +- Build cache eficiente +- Apenas dependências de produção no final +- Security: roda como usuário não-root + +### Health Checks + +Ambos os serviços têm health checks: + +**PostgreSQL:** + +- Comando: `pg_isready` +- Intervalo: 10s +- Timeout: 5s + +**Next.js App:** + +- Endpoint: `http://localhost:3000/api/health` +- Intervalo: 30s +- Start period: 40s (aguarda build) + +### Volumes e Persistência + +```yaml +volumes: + postgres_data: + name: opensheets_postgres_data + driver: local +``` + +- Os dados do PostgreSQL **persistem** entre restarts +- Para **apagar dados**: `pnpm docker:down:volumes` +- Para **backup**: `docker compose exec db pg_dump...` + +### Network Isolada + +```yaml +networks: + opensheets_network: + name: opensheets_network + driver: bridge +``` + +- App e banco se comunicam via network interna +- Isolamento de segurança +- DNS automático (app acessa `db:5432`) + +### Comandos Docker Avançados + +```bash +# Entrar no container da aplicação +docker compose exec app sh + +# Entrar no container do banco +docker compose exec db psql -U opensheets -d opensheets_db + +# Ver status dos containers +docker compose ps + +# Ver uso de recursos +docker stats opensheets_app opensheets_postgres + +# Backup do banco +docker compose exec db pg_dump -U opensheets opensheets_db > backup.sql + +# Restaurar backup +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql + +# Limpar tudo (containers, volumes, images) +docker compose down -v +docker system prune -a +``` + +### Customizando Portas + +No arquivo `.env`: + +```env +# Porta da aplicação (padrão: 3000) +APP_PORT=3001 + +# Porta do banco de dados (padrão: 5432) +DB_PORT=5433 +``` + +--- + +## 🔐 Configuração de Variáveis de Ambiente + +Copie o `.env.example` para `.env` e configure: + +### Variáveis Obrigatórias + +```env +# === Database === +DATABASE_URL=postgresql://opensheets:opensheets_dev_password@localhost:5432/opensheets_db +DB_PROVIDER=local # ou "remote" + +# === Better Auth === +# Gere com: openssl rand -base64 32 +BETTER_AUTH_SECRET=seu-secret-super-secreto-aqui +BETTER_AUTH_URL=http://localhost:3000 +``` + +### Variáveis Opcionais + +#### PostgreSQL (customização) + +```env +POSTGRES_USER=opensheets +POSTGRES_PASSWORD=opensheets_dev_password +POSTGRES_DB=opensheets_db +``` + +#### Portas (customização) + +```env +APP_PORT=3000 +DB_PORT=5432 +``` + +#### OAuth Providers + +```env +GOOGLE_CLIENT_ID=seu-google-client-id +GOOGLE_CLIENT_SECRET=seu-google-client-secret + +GITHUB_CLIENT_ID=seu-github-client-id +GITHUB_CLIENT_SECRET=seu-github-client-secret +``` + +#### Email (Resend) + +```env +RESEND_API_KEY=re_seu_api_key +EMAIL_FROM=noreply@seudominio.com +``` + +#### AI Providers + +```env +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +GOOGLE_GENERATIVE_AI_API_KEY=... +OPENROUTER_API_KEY=sk-or-... +``` + +### Gerando Secrets + +```bash +# BETTER_AUTH_SECRET +openssl rand -base64 32 + +# Ou use o script automático +pnpm env:setup +``` + +--- + +## 🗄️ Banco de Dados + +### Escolhendo entre Local e Remoto + +| Modo | Quando usar | Como configurar | +| ---------- | ------------------------------------- | -------------------------------------- | +| **Local** | Desenvolvimento, testes, prototipagem | `DB_PROVIDER=local` + Docker | +| **Remoto** | Produção, deploy, banco gerenciado | `DB_PROVIDER=remote` + URL do provider | + +### Drizzle ORM + +#### Schema Definition + +Os schemas ficam em `/db/schema.ts`: + +```typescript +import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + name: text("name"), + createdAt: timestamp("created_at").defaultNow(), +}); +``` + +#### Gerando Migrations + +```bash +# Após alterar /db/schema.ts +pnpm db:generate + +# Aplica migrations +pnpm db:migrate + +# Ou push direto (dev only) +pnpm db:push +``` + +#### Drizzle Studio + +Interface visual para explorar e editar dados: + +```bash +pnpm db:studio +``` + +Abre em: `https://local.drizzle.studio` + +### Migrations Automáticas (Docker) + +No `docker-compose.yml`, migrations rodam automaticamente: + +```yaml +command: + - | + echo "📦 Rodando migrations..." + pnpm db:push + + echo "✅ Iniciando aplicação..." + node server.js +``` + +### Backup e Restore + +```bash +# Backup (banco local Docker) +docker compose exec db pg_dump -U opensheets opensheets_db > backup_$(date +%Y%m%d).sql + +# Backup (banco remoto) +pg_dump $DATABASE_URL > backup.sql + +# Restore (Docker) +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql + +# Restore (remoto) +psql $DATABASE_URL < backup.sql +``` + +--- + +## 🏗️ Arquitetura + +### Estrutura de Pastas + +``` +opensheets/ +├── app/ # Next.js App Router +│ ├── api/ # API Routes +│ │ ├── auth/ # Better Auth endpoints +│ │ └── health/ # Health check +│ ├── (dashboard)/ # Protected routes (com auth) +│ └── layout.tsx # Root layout +│ +├── components/ # React Components +│ ├── ui/ # shadcn/ui components +│ └── ... # Feature components +│ +├── lib/ # Shared utilities +│ ├── db.ts # Drizzle client +│ ├── auth.ts # Better Auth server +│ └── auth-client.ts # Better Auth client +│ +├── db/ # Drizzle schema +│ └── schema.ts # Database schema +│ +├── drizzle/ # Generated migrations +│ └── migrations/ +│ +├── hooks/ # Custom React hooks +├── public/ # Static assets +├── scripts/ # Utility scripts +│ ├── setup-env.sh # Env setup automation +│ └── postgres/init.sql # PostgreSQL init script +│ +├── docker/ # Docker configs +│ └── postgres/init.sql +│ +├── Dockerfile # Production build +├── docker-compose.yml # Docker orchestration +├── next.config.ts # Next.js config +├── drizzle.config.ts # Drizzle ORM config +├── tailwind.config.ts # Tailwind config +└── tsconfig.json # TypeScript config +``` + +### Fluxo de Autenticação + +``` +1. Usuário acessa rota protegida + ↓ +2. middleware.ts verifica sessão (Better Auth) + ↓ +3. Se não autenticado → redirect /auth + ↓ +4. Usuário faz login (OAuth ou email) + ↓ +5. Better Auth valida e cria sessão + ↓ +6. Cookie de sessão é salvo + ↓ +7. Usuário acessa rota protegida ✅ +``` + +### Fluxo de Build (Docker) + +``` +1. Stage deps: Instala dependências + ↓ +2. Stage builder: Builda Next.js (standalone) + ↓ +3. Stage runner: Copia apenas build + deps prod + ↓ +4. Container final: ~200MB (otimizado) +``` + +--- + +## 🆘 Troubleshooting + +### Erro: "DATABASE_URL env variable is not set" + +**Causa:** Arquivo `.env` não existe ou `DATABASE_URL` não configurado + +**Solução:** + +```bash +cp .env.example .env +# Edite .env e configure DATABASE_URL +``` + +--- + +### Container do app não conecta ao banco + +**Causa:** `DATABASE_URL` usa `localhost` em vez de `db` + +**Solução:** + +Para Docker, use o **nome do serviço**: + +```env +# ❌ Errado (localhost não funciona dentro do container) +DATABASE_URL=postgresql://opensheets:senha@localhost:5432/opensheets_db + +# ✅ Correto (usa nome do serviço Docker) +DATABASE_URL=postgresql://opensheets:senha@db:5432/opensheets_db +``` + +Para desenvolvimento local (sem Docker app): + +```env +# ✅ Correto (app roda local, banco em Docker) +DATABASE_URL=postgresql://opensheets:senha@localhost:5432/opensheets_db +``` + +**Verifique o status do banco:** + +```bash +docker compose ps +docker compose logs db +``` + +--- + +### Porta 3000 ou 5432 já está em uso + +**Solução:** + +Edite o `.env`: + +```env +APP_PORT=3001 +DB_PORT=5433 +``` + +Ou pare o processo que está usando: + +```bash +# Descobrir quem usa a porta +lsof -i :3000 +lsof -i :5432 + +# Matar processo +kill -9 +``` + +--- + +### Migrations não rodam + +**Com Docker:** + +Migrations rodam automaticamente no startup. Veja logs: + +```bash +pnpm docker:logs:app +``` + +Se falharem, rode manualmente: + +```bash +docker compose exec app pnpm db:push +``` + +**Sem Docker:** + +```bash +pnpm db:push +``` + +--- + +### Erro: "server.js not found" + +**Causa:** Next.js não gerou standalone build + +**Solução:** + +1. Verifique `next.config.ts`: + +```typescript +const nextConfig: NextConfig = { + output: "standalone", // ← Deve estar presente +}; +``` + +2. Rebuild: + +```bash +docker compose down +docker compose up --build +``` + +--- + +### Erro ao atualizar PostgreSQL 16 → 18 + +**Causa:** Volumes antigos são incompatíveis + +**Solução:** + +```bash +# ⚠️ ATENÇÃO: Isso apaga dados do banco local! +docker compose down -v + +# Suba novamente com PostgreSQL 18 +docker compose up --build +``` + +**Para preservar dados:** + +```bash +# 1. Backup +docker compose exec db pg_dumpall -U opensheets > backup.sql + +# 2. Limpa volumes +docker compose down -v + +# 3. Sobe PG 18 +docker compose up -d db + +# 4. Aguarda (15s) +sleep 15 + +# 5. Restaura +docker compose exec -T db psql -U opensheets -d opensheets_db < backup.sql +``` + +--- + +### Drizzle Studio não abre + +**Solução:** + +1. Verifique se o banco está rodando: + +```bash +docker compose ps +``` + +2. Teste conexão: + +```bash +psql $DATABASE_URL +``` + +3. Abra Drizzle Studio: + +```bash +pnpm db:studio +``` + +--- + +### Build do Docker muito lento + +**Causa:** Cache não está sendo aproveitado + +**Solução:** + +1. Use BuildKit: + +```bash +export DOCKER_BUILDKIT=1 +docker compose build +``` + +2. Limpe cache antigo: + +```bash +docker builder prune +``` + +3. Multi-stage build já otimiza camadas + +--- + +### "Permission denied" ao rodar Docker + +**Causa:** Usuário não está no grupo docker + +**Solução (Linux):** + +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +**Solução (Mac/Windows):** + +- Docker Desktop deve estar rodando +- Verifique configurações de permissão + +--- + +### Limpar tudo e começar do zero + +```bash +# Para containers e remove volumes +docker compose down -v + +# Remove images não usadas +docker system prune -a + +# Remove TUDO do Docker (cuidado!) +docker system prune -a --volumes + +# Rebuild do zero +pnpm docker:up +``` + +--- + +## 🤝 Contribuindo + +Contribuições são muito bem-vindas! + +### Como contribuir + +1. **Fork** o projeto +2. **Clone** seu fork + ```bash + git clone https://github.com/seu-usuario/opensheets.git + ``` +3. **Crie uma branch** para sua feature + ```bash + git checkout -b feature/minha-feature + ``` +4. **Commit** suas mudanças + ```bash + git commit -m 'feat: adiciona minha feature' + ``` +5. **Push** para a branch + ```bash + git push origin feature/minha-feature + ``` +6. Abra um **Pull Request** + +### Padrões + +- Use **TypeScript** +- Siga o **ESLint** configurado +- Documente **features novas** +- Use **commits semânticos** (feat, fix, docs, etc) + +--- + +## 📄 Licença + +Este projeto é open source e está disponível sob a [Licença MIT](LICENSE). + +--- + +## 🙏 Agradecimentos + +- [Next.js](https://nextjs.org/) +- [Better Auth](https://better-auth.com/) +- [Drizzle ORM](https://orm.drizzle.team/) +- [shadcn/ui](https://ui.shadcn.com/) +- [Vercel](https://vercel.com/) + +--- + +## 📞 Contato + +**Desenvolvido por:** Felipe Coutinho +**GitHub:** [@felipegcoutinho](https://github.com/felipegcoutinho) +**Repositório:** [opensheets](https://github.com/felipegcoutinho/opensheets) + +--- + +
+ +**⭐ Se este projeto foi útil, considere dar uma estrela!** + +Desenvolvido com ❤️ para a comunidade open source + +
diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..8e70c5e --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/auth/login-form"; + +export default function LoginPage() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..8e4c66e --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,11 @@ +import { SignupForm } from "@/components/auth/signup-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/(dashboard)/ajustes/actions.ts b/app/(dashboard)/ajustes/actions.ts new file mode 100644 index 0000000..457abda --- /dev/null +++ b/app/(dashboard)/ajustes/actions.ts @@ -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 = { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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.", + }; + } +} diff --git a/app/(dashboard)/ajustes/layout.tsx b/app/(dashboard)/ajustes/layout.tsx new file mode 100644 index 0000000..ac0bb2a --- /dev/null +++ b/app/(dashboard)/ajustes/layout.tsx @@ -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 ( +
+ } + title="Ajustes" + subtitle="Gerencie informações da conta, segurança e outras opções para otimizar sua experiência." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/ajustes/page.tsx b/app/(dashboard)/ajustes/page.tsx new file mode 100644 index 0000000..0296d65 --- /dev/null +++ b/app/(dashboard)/ajustes/page.tsx @@ -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 ( +
+ + + Altere seu nome + Alterar senha + Alterar e-mail + + Deletar conta + + + + + +
+

Alterar nome

+

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

+
+ +
+ + +
+

Alterar senha

+

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

+
+ +
+ + +
+

Alterar e-mail

+

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

+
+ +
+ + +
+

+ Deletar conta +

+

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

+
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/anotacoes/actions.ts b/app/(dashboard)/anotacoes/actions.ts new file mode 100644 index 0000000..d4646e2 --- /dev/null +++ b/app/(dashboard)/anotacoes/actions.ts @@ -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; +type NoteUpdateInput = z.infer; +type NoteDeleteInput = z.infer; + +export async function createNoteAction( + input: NoteCreateInput +): Promise { + 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 { + 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 { + 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); + } +} diff --git a/app/(dashboard)/anotacoes/data.ts b/app/(dashboard)/anotacoes/data.ts new file mode 100644 index 0000000..8128a24 --- /dev/null +++ b/app/(dashboard)/anotacoes/data.ts @@ -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 { + 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(), + }; + }); +} diff --git a/app/(dashboard)/anotacoes/layout.tsx b/app/(dashboard)/anotacoes/layout.tsx new file mode 100644 index 0000000..8e1c66b --- /dev/null +++ b/app/(dashboard)/anotacoes/layout.tsx @@ -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 ( +
+ } + title="Notas" + subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/anotacoes/loading.tsx b/app/(dashboard)/anotacoes/loading.tsx new file mode 100644 index 0000000..7ee612f --- /dev/null +++ b/app/(dashboard)/anotacoes/loading.tsx @@ -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 ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de cards de notas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Título */} + + + {/* Conteúdo (3-4 linhas) */} +
+ + + + {i % 2 === 0 && ( + + )} +
+ + {/* Footer com data e ações */} +
+ +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/anotacoes/page.tsx b/app/(dashboard)/anotacoes/page.tsx new file mode 100644 index 0000000..5dfae8f --- /dev/null +++ b/app/(dashboard)/anotacoes/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(dashboard)/calendario/data.ts b/app/(dashboard)/calendario/data.ts new file mode 100644 index 0000000..65cfc9c --- /dev/null +++ b/app/(dashboard)/calendario/data.ts @@ -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 => { + 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(); + 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 = { + 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, + }, + }; +}; diff --git a/app/(dashboard)/calendario/layout.tsx b/app/(dashboard)/calendario/layout.tsx new file mode 100644 index 0000000..3e1f720 --- /dev/null +++ b/app/(dashboard)/calendario/layout.tsx @@ -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 ( +
+ } + title="Calendário" + subtitle="Visualize lançamentos, vencimentos de cartões e boletos em um só lugar. Clique em uma data para detalhar ou criar um novo lançamento." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/calendario/loading.tsx b/app/(dashboard)/calendario/loading.tsx new file mode 100644 index 0000000..79ad556 --- /dev/null +++ b/app/(dashboard)/calendario/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ + {/* Calendar Container */} +
+ {/* Cabeçalho com dias da semana */} +
+ {["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"].map((day) => ( +
+ +
+ ))} +
+ + {/* Grade de dias (6 semanas) */} +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ {/* Número do dia */} + + + {/* Indicadores de eventos (aleatório entre 0-3) */} + {i % 3 === 0 && ( +
+ + {i % 5 === 0 && ( + + )} +
+ )} +
+ ))} +
+ + {/* Legenda */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/calendario/page.tsx b/app/(dashboard)/calendario/page.tsx new file mode 100644 index 0000000..fa24c4b --- /dev/null +++ b/app/(dashboard)/calendario/page.tsx @@ -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; + +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 ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts new file mode 100644 index 0000000..eb4e70e --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/actions.ts @@ -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 = { + [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 { + 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` + 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; + +export async function updatePaymentDateAction( + input: UpdatePaymentDateInput +): Promise { + 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.", + }; + } +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts new file mode 100644 index 0000000..bf4ec97 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/data.ts @@ -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 }; +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx new file mode 100644 index 0000000..6af1b25 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ + {/* Invoice Summary Card */} +
+ +
+ + {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ + +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+
+ ); +} diff --git a/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx new file mode 100644 index 0000000..56fa0c5 --- /dev/null +++ b/app/(dashboard)/cartoes/[cartaoId]/fatura/page.tsx @@ -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; + +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 ( +
+ + +
+ + + + } + /> + } + /> +
+ +
+ +
+
+ ); +} diff --git a/app/(dashboard)/cartoes/actions.ts b/app/(dashboard)/cartoes/actions.ts new file mode 100644 index 0000000..233a3c6 --- /dev/null +++ b/app/(dashboard)/cartoes/actions.ts @@ -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; +type CardUpdateInput = z.infer; +type CardDeleteInput = z.infer; + +async function assertAccountOwnership(userId: string, contaId: string) { + const account = await db.query.contas.findFirst({ + columns: { id: true }, + where: and(eq(contas.id, contaId), eq(contas.userId, userId)), + }); + + if (!account) { + throw new Error("Conta vinculada não encontrada."); + } +} + +export async function createCardAction( + input: CardCreateInput +): Promise { + 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 { + 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 { + 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); + } +} diff --git a/app/(dashboard)/cartoes/data.ts b/app/(dashboard)/cartoes/data.ts new file mode 100644 index 0000000..5cc4ce4 --- /dev/null +++ b/app/(dashboard)/cartoes/data.ts @@ -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`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, userId), + or(isNull(lancamentos.isSettled), eq(lancamentos.isSettled, false)) + ) + ) + .groupBy(lancamentos.cartaoId), + ]); + + const usageMap = new Map(); + usageRows.forEach((row: { cartaoId: string | null; total: number | null }) => { + if (!row.cartaoId) return; + usageMap.set(row.cartaoId, Number(row.total ?? 0)); + }); + + const 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 }; +} diff --git a/app/(dashboard)/cartoes/layout.tsx b/app/(dashboard)/cartoes/layout.tsx new file mode 100644 index 0000000..afb9b02 --- /dev/null +++ b/app/(dashboard)/cartoes/layout.tsx @@ -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 ( +
+ } + 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} +
+ ); +} diff --git a/app/(dashboard)/cartoes/loading.tsx b/app/(dashboard)/cartoes/loading.tsx new file mode 100644 index 0000000..bba11ef --- /dev/null +++ b/app/(dashboard)/cartoes/loading.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de cartões + */ +export default function CartoesLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de cartões */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + + +
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/cartoes/page.tsx b/app/(dashboard)/cartoes/page.tsx new file mode 100644 index 0000000..385570c --- /dev/null +++ b/app/(dashboard)/cartoes/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(dashboard)/categorias/[categoryId]/page.tsx b/app/(dashboard)/categorias/[categoryId]/page.tsx new file mode 100644 index 0000000..d99e722 --- /dev/null +++ b/app/(dashboard)/categorias/[categoryId]/page.tsx @@ -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>; + +type PageProps = { + params: Promise<{ categoryId: string }>; + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | 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 ( +
+ + + +
+ ); +} diff --git a/app/(dashboard)/categorias/actions.ts b/app/(dashboard)/categorias/actions.ts new file mode 100644 index 0000000..39b33ef --- /dev/null +++ b/app/(dashboard)/categorias/actions.ts @@ -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; +type CategoryUpdateInput = z.infer; +type CategoryDeleteInput = z.infer; + +export async function createCategoryAction( + input: CategoryCreateInput +): Promise { + 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 { + 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 { + 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); + } +} diff --git a/app/(dashboard)/categorias/data.ts b/app/(dashboard)/categorias/data.ts new file mode 100644 index 0000000..5cf8930 --- /dev/null +++ b/app/(dashboard)/categorias/data.ts @@ -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 { + 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, + })); +} diff --git a/app/(dashboard)/categorias/layout.tsx b/app/(dashboard)/categorias/layout.tsx new file mode 100644 index 0000000..3e5e8ea --- /dev/null +++ b/app/(dashboard)/categorias/layout.tsx @@ -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 ( +
+ } + title="Categorias" + subtitle="Gerencie suas categorias de despesas e receitas. Acompanhe o desempenho financeiro por categoria e faça ajustes conforme necessário." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/categorias/loading.tsx b/app/(dashboard)/categorias/loading.tsx new file mode 100644 index 0000000..e3bba35 --- /dev/null +++ b/app/(dashboard)/categorias/loading.tsx @@ -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 ( +
+
+ {/* Header */} +
+ + +
+ + {/* Tabs */} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + {/* Grid de cards de categorias */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ {/* Ícone + Nome */} +
+ +
+ + +
+
+ + {/* Descrição */} + {i % 3 === 0 && ( + + )} + + {/* Botões de ação */} +
+ + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/app/(dashboard)/categorias/page.tsx b/app/(dashboard)/categorias/page.tsx new file mode 100644 index 0000000..4f7b060 --- /dev/null +++ b/app/(dashboard)/categorias/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/data.ts b/app/(dashboard)/contas/[contaId]/extrato/data.ts new file mode 100644 index 0000000..9967408 --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/data.ts @@ -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 { + const [periodSummary] = await db + .select({ + netAmount: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + else ${lancamentos.amount} + end + ), + 0 + ) + `, + incomes: sql` + coalesce( + sum( + case + when ${lancamentos.note} = ${INITIAL_BALANCE_NOTE} then 0 + when ${lancamentos.transactionType} = 'Receita' then ${lancamentos.amount} + else 0 + end + ), + 0 + ) + `, + expenses: sql` + 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` + 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, + }; +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/loading.tsx b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx new file mode 100644 index 0000000..c0825f0 --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ + {/* Account Statement Card */} + + + {/* Seção de lançamentos */} +
+
+ {/* Header */} +
+ +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+
+ ); +} diff --git a/app/(dashboard)/contas/[contaId]/extrato/page.tsx b/app/(dashboard)/contas/[contaId]/extrato/page.tsx new file mode 100644 index 0000000..fe013eb --- /dev/null +++ b/app/(dashboard)/contas/[contaId]/extrato/page.tsx @@ -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; + +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 ( +
+ + + + + + } + /> + } + /> + +
+ +
+
+ ); +} diff --git a/app/(dashboard)/contas/actions.ts b/app/(dashboard)/contas/actions.ts new file mode 100644 index 0000000..98df16e --- /dev/null +++ b/app/(dashboard)/contas/actions.ts @@ -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; +type AccountUpdateInput = z.infer; +type AccountDeleteInput = z.infer; + +export async function createAccountAction( + input: AccountCreateInput +): Promise { + 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 { + 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 { + 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; + +export async function transferBetweenAccountsAction( + input: TransferInput +): Promise { + 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); + } +} diff --git a/app/(dashboard)/contas/data.ts b/app/(dashboard)/contas/data.ts new file mode 100644 index 0000000..a5e1cc7 --- /dev/null +++ b/app/(dashboard)/contas/data.ts @@ -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` + 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 }; +} diff --git a/app/(dashboard)/contas/layout.tsx b/app/(dashboard)/contas/layout.tsx new file mode 100644 index 0000000..5436831 --- /dev/null +++ b/app/(dashboard)/contas/layout.tsx @@ -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 ( +
+ } + 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} +
+ ); +} diff --git a/app/(dashboard)/contas/loading.tsx b/app/(dashboard)/contas/loading.tsx new file mode 100644 index 0000000..ad9573f --- /dev/null +++ b/app/(dashboard)/contas/loading.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state para a página de contas + */ +export default function ContasLoading() { + return ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de contas */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/contas/page.tsx b/app/(dashboard)/contas/page.tsx new file mode 100644 index 0000000..3101225 --- /dev/null +++ b/app/(dashboard)/contas/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(dashboard)/dashboard/loading.tsx b/app/(dashboard)/dashboard/loading.tsx new file mode 100644 index 0000000..f725355 --- /dev/null +++ b/app/(dashboard)/dashboard/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ + {/* Dashboard content skeleton */} + +
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..7ee44bd --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -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>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | 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 ( +
+ + + + +
+ ); +} diff --git a/app/(dashboard)/insights/actions.ts b/app/(dashboard)/insights/actions.ts new file mode 100644 index 0000000..8aa2362 --- /dev/null +++ b/app/(dashboard)/insights/actions.ts @@ -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 = + | { 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`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, previousPeriod), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, twoMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + db + .select({ + transactionType: lancamentos.transactionType, + totalAmount: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, threeMonthsAgo), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(lancamentos.transactionType), + ]); + + // Calcular totais dos últimos 3 meses + let currentIncome = 0; + let currentExpense = 0; + let previousIncome = 0; + let previousExpense = 0; + let twoMonthsAgoIncome = 0; + let twoMonthsAgoExpense = 0; + let threeMonthsAgoIncome = 0; + let threeMonthsAgoExpense = 0; + + 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`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .innerJoin(categorias, eq(lancamentos.categoriaId, categorias.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + eq(categorias.type, "despesa"), + or( + isNull(lancamentos.note), + sql`${ + lancamentos.note + } NOT LIKE ${`${ACCOUNT_AUTO_INVOICE_NOTE_PREFIX}%`}` + ) + ) + ) + .groupBy(categorias.name) + .orderBy(sql`sum(${lancamentos.amount}) ASC`) + .limit(5); + + // Buscar orçamentos e uso + const budgetsData = await db + .select({ + categoryName: categorias.name, + budgetAmount: orcamentos.amount, + spent: sql`coalesce(sum(${lancamentos.amount}), 0)`, + }) + .from(orcamentos) + .innerJoin(categorias, eq(orcamentos.categoriaId, categorias.id)) + .leftJoin( + lancamentos, + and( + eq(lancamentos.categoriaId, categorias.id), + eq(lancamentos.period, period), + eq(lancamentos.userId, userId), + eq(lancamentos.transactionType, "Despesa") + ) + ) + .where(and(eq(orcamentos.userId, userId), eq(orcamentos.period, period))) + .groupBy(categorias.name, orcamentos.amount); + + // Buscar métricas de cartões + const cardsData = await db + .select({ + totalLimit: sql`coalesce(sum(${cartoes.limit}), 0)`, + cardCount: sql`count(*)`, + }) + .from(cartoes) + .where(and(eq(cartoes.userId, userId), eq(cartoes.status, "ativo"))); + + // Buscar saldo total das contas + const accountsData = await db + .select({ + totalBalance: sql`coalesce(sum(${contas.initialBalance}), 0)`, + accountCount: sql`count(*)`, + }) + .from(contas) + .where( + and( + eq(contas.userId, userId), + eq(contas.status, "ativa"), + eq(contas.excludeFromBalance, false) + ) + ); + + // Calcular ticket médio das transações + const avgTicketData = await db + .select({ + avgAmount: sql`coalesce(avg(abs(${lancamentos.amount})), 0)`, + transactionCount: sql`count(*)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(pagadores.role, PAGADOR_ROLE_ADMIN), + ne(lancamentos.transactionType, TRANSFERENCIA) + ) + ); + + // Buscar gastos por dia da semana + const dayOfWeekSpending = await db + .select({ + purchaseDate: lancamentos.purchaseDate, + amount: lancamentos.amount, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ); + + // Agregar por dia da semana + const dayTotals = new Map(); + for (const row of dayOfWeekSpending) { + if (!row.purchaseDate) continue; + const dayOfWeek = getDay(new Date(row.purchaseDate)); + const current = dayTotals.get(dayOfWeek) ?? 0; + dayTotals.set(dayOfWeek, current + Math.abs(toNumber(row.amount))); + } + + // Buscar métodos de pagamento (agregado) + const paymentMethodsData = await db + .select({ + paymentMethod: lancamentos.paymentMethod, + total: sql`coalesce(sum(abs(${lancamentos.amount})), 0)`, + }) + .from(lancamentos) + .innerJoin(pagadores, eq(lancamentos.pagadorId, pagadores.id)) + .where( + and( + eq(lancamentos.userId, userId), + eq(lancamentos.period, period), + eq(lancamentos.transactionType, "Despesa"), + eq(pagadores.role, PAGADOR_ROLE_ADMIN) + ) + ) + .groupBy(lancamentos.paymentMethod); + + // Buscar 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>(); + + 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> { + 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> { + 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> { + 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.", + }; + } +} diff --git a/app/(dashboard)/insights/data.ts b/app/(dashboard)/insights/data.ts new file mode 100644 index 0000000..506eed8 --- /dev/null +++ b/app/(dashboard)/insights/data.ts @@ -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": [...] + } + ] +} + +`; diff --git a/app/(dashboard)/insights/layout.tsx b/app/(dashboard)/insights/layout.tsx new file mode 100644 index 0000000..2179bb0 --- /dev/null +++ b/app/(dashboard)/insights/layout.tsx @@ -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 ( +
+ } + title="Insights" + subtitle="Análise inteligente dos seus dados financeiros para identificar padrões, comportamentos e oportunidades de melhoria." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/insights/loading.tsx b/app/(dashboard)/insights/loading.tsx new file mode 100644 index 0000000..52aad2c --- /dev/null +++ b/app/(dashboard)/insights/loading.tsx @@ -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 ( +
+
+ {/* Header */} +
+ + +
+ + {/* Grid de insights */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ + + +
+ +
+
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/insights/page.tsx b/app/(dashboard)/insights/page.tsx new file mode 100644 index 0000000..c8ad780 --- /dev/null +++ b/app/(dashboard)/insights/page.tsx @@ -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>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | 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 ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/lancamentos/actions.ts b/app/(dashboard)/lancamentos/actions.ts new file mode 100644 index 0000000..41d059e --- /dev/null +++ b/app/(dashboard)/lancamentos/actions.ts @@ -0,0 +1,1403 @@ +"use server"; + +import { contas, lancamentos } from "@/db/schema"; +import { + INITIAL_BALANCE_CONDITION, + INITIAL_BALANCE_NOTE, + INITIAL_BALANCE_PAYMENT_METHOD, + INITIAL_BALANCE_TRANSACTION_TYPE, +} from "@/lib/accounts/constants"; +import { handleActionError, revalidateForEntity } from "@/lib/actions/helpers"; +import type { ActionResult } from "@/lib/actions/types"; +import { db } from "@/lib/db"; +import { getUser } from "@/lib/auth/server"; +import { + LANCAMENTO_CONDITIONS, + LANCAMENTO_PAYMENT_METHODS, + LANCAMENTO_TRANSACTION_TYPES, +} from "@/lib/lancamentos/constants"; +import { + buildEntriesByPagador, + sendPagadorAutoEmails, +} from "@/lib/pagadores/notifications"; +import { noteSchema, uuidSchema } from "@/lib/schemas/common"; +import { formatDecimalForDbRequired } from "@/lib/utils/currency"; +import { getTodayDateString } from "@/lib/utils/date"; +import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; + +const resolvePeriod = (purchaseDate: string, period?: string | null) => { + if (period && /^\d{4}-\d{2}$/.test(period)) { + return period; + } + + const date = new Date(purchaseDate); + if (Number.isNaN(date.getTime())) { + throw new Error("Data da transação inválida."); + } + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; +}; + +const getTodayDate = () => new Date(getTodayDateString()); + +const baseFields = z.object({ + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + transactionType: z + .enum(LANCAMENTO_TRANSACTION_TYPES, { + message: "Selecione um tipo de transação válido.", + }) + .default(LANCAMENTO_TRANSACTION_TYPES[0]), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + condition: z.enum(LANCAMENTO_CONDITIONS, { + message: "Selecione uma condição válida.", + }), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS, { + message: "Selecione uma forma de pagamento válida.", + }), + pagadorId: uuidSchema("Pagador").nullable().optional(), + secondaryPagadorId: uuidSchema("Pagador secundário").optional(), + isSplit: z.boolean().optional().default(false), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + installmentCount: z.coerce + .number() + .int() + .min(1, "Selecione uma quantidade válida.") + .max(60, "Selecione uma quantidade válida.") + .optional(), + recurrenceCount: z.coerce + .number() + .int() + .min(1, "Selecione uma recorrência válida.") + .max(60, "Selecione uma recorrência válida.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional(), + isSettled: z.boolean().nullable().optional(), +}); + +const refineLancamento = ( + data: z.infer & { id?: string }, + ctx: z.RefinementCtx +) => { + if (data.condition === "Parcelado") { + if (!data.installmentCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Informe a quantidade de parcelas.", + }); + } else if (data.installmentCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["installmentCount"], + message: "Selecione pelo menos duas parcelas.", + }); + } + } + + if (data.condition === "Recorrente") { + if (!data.recurrenceCount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "Informe por quantos meses a recorrência acontecerá.", + }); + } else if (data.recurrenceCount < 2) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["recurrenceCount"], + message: "A recorrência deve ter ao menos dois meses.", + }); + } + } + + if (data.isSplit) { + if (!data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["pagadorId"], + message: "Selecione o pagador principal para dividir o lançamento.", + }); + } + + if (!data.secondaryPagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Selecione o pagador secundário para dividir o lançamento.", + }); + } else if (data.pagadorId && data.secondaryPagadorId === data.pagadorId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["secondaryPagadorId"], + message: "Escolha um pagador diferente para dividir o lançamento.", + }); + } + } +}; + +const createSchema = baseFields.superRefine(refineLancamento); +const updateSchema = baseFields + .extend({ + id: uuidSchema("Lançamento"), + }) + .superRefine(refineLancamento); + +const deleteSchema = z.object({ + id: uuidSchema("Lançamento"), +}); + +const toggleSettlementSchema = z.object({ + id: uuidSchema("Lançamento"), + value: z.boolean({ + message: "Informe o status de pagamento.", + }), +}); + +type BaseInput = z.infer; +type CreateInput = z.infer; +type UpdateInput = z.infer; +type DeleteInput = z.infer; +type ToggleSettlementInput = z.infer; + +const revalidate = () => revalidateForEntity("lancamentos"); + +const resolveUserLabel = (user: { + name?: string | null; + email?: string | null; +}) => { + if (user?.name && user.name.trim().length > 0) { + return user.name; + } + if (user?.email && user.email.trim().length > 0) { + return user.email; + } + return "OpenSheets"; +}; + +type InitialCandidate = { + note: string | null; + transactionType: string | null; + condition: string | null; + paymentMethod: string | null; +}; + +const isInitialBalanceLancamento = (record?: InitialCandidate | null) => + !!record && + record.note === INITIAL_BALANCE_NOTE && + record.transactionType === INITIAL_BALANCE_TRANSACTION_TYPE && + record.condition === INITIAL_BALANCE_CONDITION && + record.paymentMethod === INITIAL_BALANCE_PAYMENT_METHOD; + +const centsToDecimalString = (value: number) => { + const decimal = value / 100; + const formatted = decimal.toFixed(2); + return Object.is(decimal, -0) ? "0.00" : formatted; +}; + +const splitAmount = (totalCents: number, parts: number) => { + if (parts <= 0) { + return []; + } + + const base = Math.trunc(totalCents / parts); + const remainder = totalCents % parts; + + return Array.from( + { length: parts }, + (_, index) => base + (index < remainder ? 1 : 0) + ); +}; + +const addMonthsToPeriod = (period: string, offset: number) => { + const [yearStr, monthStr] = period.split("-"); + const baseYear = Number(yearStr); + const baseMonth = Number(monthStr); + + if (!baseYear || !baseMonth) { + throw new Error("Período inválido."); + } + + const date = new Date(baseYear, baseMonth - 1, 1); + date.setMonth(date.getMonth() + offset); + + const nextYear = date.getFullYear(); + const nextMonth = String(date.getMonth() + 1).padStart(2, "0"); + return `${nextYear}-${nextMonth}`; +}; + +const addMonthsToDate = (value: Date, offset: number) => { + const result = new Date(value); + const originalDay = result.getDate(); + + result.setDate(1); + result.setMonth(result.getMonth() + offset); + + const lastDay = new Date( + result.getFullYear(), + result.getMonth() + 1, + 0 + ).getDate(); + + result.setDate(Math.min(originalDay, lastDay)); + return result; +}; + +type Share = { + pagadorId: string | null; + amountCents: number; +}; + +const buildShares = ({ + totalCents, + pagadorId, + isSplit, + secondaryPagadorId, +}: { + totalCents: number; + pagadorId: string | null; + isSplit: boolean; + secondaryPagadorId?: string; +}): Share[] => { + if (isSplit) { + if (!pagadorId || !secondaryPagadorId) { + throw new Error("Configuração de divisão inválida para o lançamento."); + } + + const [primaryAmount, secondaryAmount] = splitAmount(totalCents, 2); + return [ + { pagadorId, amountCents: primaryAmount }, + { pagadorId: secondaryPagadorId, amountCents: secondaryAmount }, + ]; + } + + return [{ pagadorId, amountCents: totalCents }]; +}; + +type BuildLancamentoRecordsParams = { + data: BaseInput; + userId: string; + period: string; + purchaseDate: Date; + dueDate: Date | null; + boletoPaymentDate: Date | null; + shares: Share[]; + amountSign: 1 | -1; + shouldNullifySettled: boolean; + seriesId: string | null; +}; + +type LancamentoInsert = typeof lancamentos.$inferInsert; + +const buildLancamentoRecords = ({ + data, + userId, + period, + purchaseDate, + dueDate, + boletoPaymentDate, + shares, + amountSign, + shouldNullifySettled, + seriesId, +}: BuildLancamentoRecordsParams): LancamentoInsert[] => { + const records: LancamentoInsert[] = []; + + const basePayload = { + name: data.name, + transactionType: data.transactionType, + condition: data.condition, + paymentMethod: data.paymentMethod, + note: data.note ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + recurrenceCount: null as number | null, + installmentCount: null as number | null, + currentInstallment: null as number | null, + isDivided: data.isSplit ?? false, + userId, + seriesId, + }; + + const resolveSettledValue = (cycleIndex: number) => { + if (shouldNullifySettled) { + return null; + } + const initialSettled = data.isSettled ?? false; + if (data.condition === "Parcelado" || data.condition === "Recorrente") { + return cycleIndex === 0 ? initialSettled : false; + } + return initialSettled; + }; + + if (data.condition === "Parcelado") { + const installmentTotal = data.installmentCount ?? 0; + const amountsByShare = shares.map((share) => + splitAmount(share.amountCents, installmentTotal) + ); + + for ( + let installment = 0; + installment < installmentTotal; + installment += 1 + ) { + const installmentPeriod = addMonthsToPeriod(period, installment); + const installmentPurchaseDate = addMonthsToDate( + purchaseDate, + installment + ); + const installmentDueDate = dueDate + ? addMonthsToDate(dueDate, installment) + : null; + + shares.forEach((share, shareIndex) => { + const amountCents = amountsByShare[shareIndex]?.[installment] ?? 0; + const settled = resolveSettledValue(installment); + records.push({ + ...basePayload, + amount: centsToDecimalString(amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: installmentPurchaseDate, + period: installmentPeriod, + isSettled: settled, + installmentCount: installmentTotal, + currentInstallment: installment + 1, + recurrenceCount: null, + dueDate: installmentDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } + + return records; + } + + if (data.condition === "Recorrente") { + const recurrenceTotal = data.recurrenceCount ?? 0; + + for (let index = 0; index < recurrenceTotal; index += 1) { + const recurrencePeriod = addMonthsToPeriod(period, index); + const recurrencePurchaseDate = addMonthsToDate(purchaseDate, index); + const recurrenceDueDate = dueDate + ? addMonthsToDate(dueDate, index) + : null; + + shares.forEach((share) => { + const settled = resolveSettledValue(index); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate: recurrencePurchaseDate, + period: recurrencePeriod, + isSettled: settled, + recurrenceCount: recurrenceTotal, + dueDate: recurrenceDueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled + ? boletoPaymentDate + : null, + }); + }); + } + + return records; + } + + shares.forEach((share) => { + const settled = resolveSettledValue(0); + records.push({ + ...basePayload, + amount: centsToDecimalString(share.amountCents * amountSign), + pagadorId: share.pagadorId, + purchaseDate, + period, + isSettled: settled, + dueDate, + boletoPaymentDate: + data.paymentMethod === "Boleto" && settled ? boletoPaymentDate : null, + }); + }); + + return records; +}; + +export async function createLancamentoAction( + input: CreateInput +): Promise { + try { + const user = await getUser(); + const data = createSchema.parse(input); + + const period = resolvePeriod(data.purchaseDate, data.period); + const purchaseDate = new Date(data.purchaseDate); + const dueDate = data.dueDate ? new Date(data.dueDate) : null; + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && (data.isSettled ?? false); + const boletoPaymentDate = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : getTodayDate() + : null; + + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(data.amount) * 100); + const shouldNullifySettled = data.paymentMethod === "Cartão de crédito"; + + const shares = buildShares({ + totalCents, + pagadorId: data.pagadorId ?? null, + isSplit: data.isSplit ?? false, + secondaryPagadorId: data.secondaryPagadorId, + }); + + const isSeriesLancamento = + data.condition === "Parcelado" || data.condition === "Recorrente"; + const seriesId = isSeriesLancamento ? randomUUID() : null; + + const records = buildLancamentoRecords({ + data, + userId: user.id, + period, + purchaseDate, + dueDate, + shares, + amountSign, + shouldNullifySettled, + boletoPaymentDate, + seriesId, + }); + + if (!records.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } + + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(records); + }); + + const notificationEntries = buildEntriesByPagador( + records.map((record) => ({ + pagadorId: record.pagadorId ?? null, + name: record.name ?? null, + amount: record.amount ?? null, + transactionType: record.transactionType ?? null, + paymentMethod: record.paymentMethod ?? null, + condition: record.condition ?? null, + purchaseDate: record.purchaseDate ?? null, + period: record.period ?? null, + note: record.note ?? null, + })) + ); + + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + return { success: true, message: "Lançamento criado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function updateLancamentoAction( + input: UpdateInput +): Promise { + try { + const user = await getUser(); + const data = updateSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + note: true, + transactionType: true, + condition: true, + paymentMethod: true, + contaId: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + // Bloquear edição de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção de valores + const categoriasProtegidasEdicao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasEdicao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser editados.`, + }; + } + + const period = resolvePeriod(data.purchaseDate, data.period); + const amountSign: 1 | -1 = data.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + const normalizedAmount = centsToDecimalString(amountCents * amountSign); + const normalizedSettled = + data.paymentMethod === "Cartão de crédito" + ? null + : data.isSettled ?? false; + const shouldSetBoletoPaymentDate = + data.paymentMethod === "Boleto" && Boolean(normalizedSettled); + const boletoPaymentDateValue = shouldSetBoletoPaymentDate + ? data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : getTodayDate() + : null; + + await db + .update(lancamentos) + .set({ + name: data.name, + purchaseDate: new Date(data.purchaseDate), + transactionType: data.transactionType, + amount: normalizedAmount, + condition: data.condition, + paymentMethod: data.paymentMethod, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + isSettled: normalizedSettled, + installmentCount: data.installmentCount ?? null, + recurrenceCount: data.recurrenceCount ?? null, + dueDate: data.dueDate ? new Date(data.dueDate) : null, + boletoPaymentDate: boletoPaymentDateValue, + period, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + if (isInitialBalanceLancamento(existing) && existing?.contaId) { + const updatedInitialBalance = formatDecimalForDbRequired( + Math.abs(data.amount ?? 0) + ); + await db + .update(contas) + .set({ initialBalance: updatedInitialBalance }) + .where( + and(eq(contas.id, existing.contaId), eq(contas.userId, user.id)) + ); + } + + revalidate(); + + return { success: true, message: "Lançamento atualizado com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function deleteLancamentoAction( + input: DeleteInput +): Promise { + try { + const user = await getUser(); + const data = deleteSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + categoriaId: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + with: { + categoria: { + columns: { + name: true, + }, + }, + }, + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + // Bloquear remoção de lançamentos com categorias protegidas + // Nota: "Transferência interna" foi removida para permitir correção/exclusão + const categoriasProtegidasRemocao = ["Saldo inicial", "Pagamentos"]; + if ( + existing.categoria?.name && + categoriasProtegidasRemocao.includes(existing.categoria.name) + ) { + return { + success: false, + error: `Lançamentos com a categoria '${existing.categoria.name}' não podem ser removidos.`, + }; + } + + await db + .delete(lancamentos) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + if (existing.pagadorId) { + const notificationEntries = buildEntriesByPagador([ + { + pagadorId: existing.pagadorId, + name: existing.name ?? null, + amount: existing.amount ?? null, + transactionType: existing.transactionType ?? null, + paymentMethod: existing.paymentMethod ?? null, + condition: existing.condition ?? null, + purchaseDate: existing.purchaseDate ?? null, + period: existing.period ?? null, + note: existing.note ?? null, + }, + ]); + + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + return { success: true, message: "Lançamento removido com sucesso." }; + } catch (error) { + return handleActionError(error); + } +} + +export async function toggleLancamentoSettlementAction( + input: ToggleSettlementInput +): Promise { + try { + const user = await getUser(); + const data = toggleSettlementSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { id: true, paymentMethod: true }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (existing.paymentMethod === "Cartão de crédito") { + return { + success: false, + error: "Pagamentos com cartão são conciliados automaticamente.", + }; + } + + const isBoleto = existing.paymentMethod === "Boleto"; + const boletoPaymentDate = isBoleto + ? data.value + ? getTodayDate() + : null + : null; + + await db + .update(lancamentos) + .set({ + isSettled: data.value, + boletoPaymentDate, + }) + .where(and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id))); + + revalidate(); + + return { + success: true, + message: data.value + ? "Lançamento marcado como pago." + : "Pagamento desfeito com sucesso.", + }; + } catch (error) { + return handleActionError(error); + } +} + +const deleteBulkSchema = z.object({ + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), +}); + +type DeleteBulkInput = z.infer; + +export async function deleteLancamentoBulkAction( + input: DeleteBulkInput +): Promise { + try { + const user = await getUser(); + const data = deleteBulkSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } + + if (data.scope === "current") { + await db + .delete(lancamentos) + .where( + and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)) + ); + + revalidate(); + return { success: true, message: "Lançamento removido com sucesso." }; + } + + if (data.scope === "future") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}` + ) + ); + + revalidate(); + return { + success: true, + message: "Lançamentos removidos com sucesso.", + }; + } + + if (data.scope === "all") { + await db + .delete(lancamentos) + .where( + and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id) + ) + ); + + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram removidos.", + }; + } + + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } +} + +const updateBulkSchema = z.object({ + id: uuidSchema("Lançamento"), + scope: z.enum(["current", "future", "all"], { + message: "Escopo de ação inválido.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + categoriaId: uuidSchema("Categoria").nullable().optional(), + note: noteSchema, + pagadorId: uuidSchema("Pagador").nullable().optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero.") + .optional(), + dueDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de vencimento válida.", + }) + .optional() + .nullable(), + boletoPaymentDate: z + .string() + .trim() + .refine((value) => !value || !Number.isNaN(new Date(value).getTime()), { + message: "Informe uma data de pagamento válida.", + }) + .optional() + .nullable(), +}); + +type UpdateBulkInput = z.infer; + +export async function updateLancamentoBulkAction( + input: UpdateBulkInput +): Promise { + try { + const user = await getUser(); + const data = updateBulkSchema.parse(input); + + const existing = await db.query.lancamentos.findFirst({ + columns: { + id: true, + name: true, + seriesId: true, + period: true, + condition: true, + transactionType: true, + purchaseDate: true, + }, + where: and(eq(lancamentos.id, data.id), eq(lancamentos.userId, user.id)), + }); + + if (!existing) { + return { success: false, error: "Lançamento não encontrado." }; + } + + if (!existing.seriesId) { + return { + success: false, + error: "Este lançamento não faz parte de uma série.", + }; + } + + const baseUpdatePayload: Record = { + name: data.name, + categoriaId: data.categoriaId ?? null, + note: data.note ?? null, + pagadorId: data.pagadorId ?? null, + contaId: data.contaId ?? null, + cartaoId: data.cartaoId ?? null, + }; + + if (data.amount !== undefined) { + const amountSign: 1 | -1 = + existing.transactionType === "Despesa" ? -1 : 1; + const amountCents = Math.round(Math.abs(data.amount) * 100); + baseUpdatePayload.amount = centsToDecimalString(amountCents * amountSign); + } + + const hasDueDateUpdate = data.dueDate !== undefined; + const hasBoletoPaymentDateUpdate = data.boletoPaymentDate !== undefined; + + const baseDueDate = + hasDueDateUpdate && data.dueDate + ? new Date(data.dueDate) + : hasDueDateUpdate + ? null + : undefined; + + const baseBoletoPaymentDate = + hasBoletoPaymentDateUpdate && data.boletoPaymentDate + ? new Date(data.boletoPaymentDate) + : hasBoletoPaymentDateUpdate + ? null + : undefined; + + const basePurchaseDate = existing.purchaseDate ?? null; + + const buildDueDateForRecord = (recordPurchaseDate: Date | null) => { + if (!hasDueDateUpdate) { + return undefined; + } + + if (!baseDueDate) { + return null; + } + + if (!basePurchaseDate || !recordPurchaseDate) { + return baseDueDate; + } + + const monthDiff = + (recordPurchaseDate.getFullYear() - basePurchaseDate.getFullYear()) * + 12 + + (recordPurchaseDate.getMonth() - basePurchaseDate.getMonth()); + + return addMonthsToDate(baseDueDate, monthDiff); + }; + + const applyUpdates = async ( + records: Array<{ id: string; purchaseDate: Date | null }> + ) => { + if (records.length === 0) { + return; + } + + await db.transaction(async (tx: typeof db) => { + for (const record of records) { + const perRecordPayload: Record = { + ...baseUpdatePayload, + }; + + const dueDateForRecord = buildDueDateForRecord(record.purchaseDate); + if (dueDateForRecord !== undefined) { + perRecordPayload.dueDate = dueDateForRecord; + } + + if (hasBoletoPaymentDateUpdate) { + perRecordPayload.boletoPaymentDate = baseBoletoPaymentDate ?? null; + } + + await tx + .update(lancamentos) + .set(perRecordPayload) + .where( + and( + eq(lancamentos.id, record.id), + eq(lancamentos.userId, user.id) + ) + ); + } + }); + }; + + if (data.scope === "current") { + await applyUpdates([ + { + id: data.id, + purchaseDate: existing.purchaseDate ?? null, + }, + ]); + + revalidate(); + return { success: true, message: "Lançamento atualizado com sucesso." }; + } + + if (data.scope === "future") { + const futureLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id), + sql`${lancamentos.period} >= ${existing.period}` + ), + orderBy: asc(lancamentos.purchaseDate), + }); + + await applyUpdates( + futureLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })) + ); + + revalidate(); + return { + success: true, + message: "Lançamentos atualizados com sucesso.", + }; + } + + if (data.scope === "all") { + const allLancamentos = await db.query.lancamentos.findMany({ + columns: { + id: true, + purchaseDate: true, + }, + where: and( + eq(lancamentos.seriesId, existing.seriesId), + eq(lancamentos.userId, user.id) + ), + orderBy: asc(lancamentos.purchaseDate), + }); + + await applyUpdates( + allLancamentos.map((item) => ({ + id: item.id, + purchaseDate: item.purchaseDate ?? null, + })) + ); + + revalidate(); + return { + success: true, + message: "Todos os lançamentos da série foram atualizados.", + }; + } + + return { success: false, error: "Escopo de ação inválido." }; + } catch (error) { + return handleActionError(error); + } +} + +// Mass Add Schema +const massAddTransactionSchema = z.object({ + purchaseDate: z + .string({ message: "Informe a data da transação." }) + .trim() + .refine((value) => !Number.isNaN(new Date(value).getTime()), { + message: "Data da transação inválida.", + }), + name: z + .string({ message: "Informe o estabelecimento." }) + .trim() + .min(1, "Informe o estabelecimento."), + amount: z.coerce + .number({ message: "Informe o valor da transação." }) + .min(0, "Informe um valor maior ou igual a zero."), + categoriaId: uuidSchema("Categoria").nullable().optional(), +}); + +const massAddSchema = z.object({ + fixedFields: z.object({ + transactionType: z.enum(LANCAMENTO_TRANSACTION_TYPES).optional(), + pagadorId: uuidSchema("Pagador").nullable().optional(), + paymentMethod: z.enum(LANCAMENTO_PAYMENT_METHODS).optional(), + condition: z.enum(LANCAMENTO_CONDITIONS).optional(), + period: z + .string() + .trim() + .regex(/^(\d{4})-(\d{2})$/, { + message: "Selecione um período válido.", + }) + .optional(), + contaId: uuidSchema("Conta").nullable().optional(), + cartaoId: uuidSchema("Cartão").nullable().optional(), + }), + transactions: z + .array(massAddTransactionSchema) + .min(1, "Adicione pelo menos uma transação."), +}); + +type MassAddInput = z.infer; + +export async function createMassLancamentosAction( + input: MassAddInput +): Promise { + try { + const user = await getUser(); + const data = massAddSchema.parse(input); + + // Default values for non-fixed fields + const defaultTransactionType = LANCAMENTO_TRANSACTION_TYPES[0]; + const defaultCondition = LANCAMENTO_CONDITIONS[0]; + const defaultPaymentMethod = LANCAMENTO_PAYMENT_METHODS[0]; + + const allRecords: LancamentoInsert[] = []; + const notificationData: Array<{ + pagadorId: string | null; + name: string | null; + amount: string | null; + transactionType: string | null; + paymentMethod: string | null; + condition: string | null; + purchaseDate: Date | null; + period: string | null; + note: string | null; + }> = []; + + // Process each transaction + for (const transaction of data.transactions) { + const transactionType = + data.fixedFields.transactionType ?? defaultTransactionType; + const condition = data.fixedFields.condition ?? defaultCondition; + const paymentMethod = + data.fixedFields.paymentMethod ?? defaultPaymentMethod; + const pagadorId = data.fixedFields.pagadorId ?? null; + const contaId = + paymentMethod === "Cartão de crédito" + ? null + : data.fixedFields.contaId ?? null; + const cartaoId = + paymentMethod === "Cartão de crédito" + ? data.fixedFields.cartaoId ?? null + : null; + const categoriaId = transaction.categoriaId ?? null; + + const period = + data.fixedFields.period ?? resolvePeriod(transaction.purchaseDate); + const purchaseDate = new Date(transaction.purchaseDate); + const amountSign: 1 | -1 = transactionType === "Despesa" ? -1 : 1; + const totalCents = Math.round(Math.abs(transaction.amount) * 100); + const amount = centsToDecimalString(totalCents * amountSign); + const isSettled = paymentMethod === "Cartão de crédito" ? null : false; + + const record: LancamentoInsert = { + name: transaction.name, + purchaseDate, + period, + transactionType, + amount, + condition, + paymentMethod, + pagadorId, + contaId, + cartaoId, + categoriaId, + note: null, + installmentCount: null, + recurrenceCount: null, + currentInstallment: null, + isSettled, + isDivided: false, + dueDate: null, + boletoPaymentDate: null, + userId: user.id, + seriesId: null, + }; + + allRecords.push(record); + + notificationData.push({ + pagadorId, + name: transaction.name, + amount, + transactionType, + paymentMethod, + condition, + purchaseDate, + period, + note: null, + }); + } + + if (!allRecords.length) { + throw new Error("Não foi possível criar os lançamentos solicitados."); + } + + // Insert all records in a single transaction + await db.transaction(async (tx: typeof db) => { + await tx.insert(lancamentos).values(allRecords); + }); + + // Send notifications + const notificationEntries = buildEntriesByPagador(notificationData); + + if (notificationEntries.size > 0) { + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "created", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + const count = allRecords.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento criado" : "lançamentos criados" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } +} + +// Delete multiple lancamentos at once +const deleteMultipleSchema = z.object({ + ids: z + .array(uuidSchema("Lançamento")) + .min(1, "Selecione pelo menos um lançamento."), +}); + +type DeleteMultipleInput = z.infer; + +export async function deleteMultipleLancamentosAction( + input: DeleteMultipleInput +): Promise { + try { + const user = await getUser(); + const data = deleteMultipleSchema.parse(input); + + // Fetch all lancamentos to be deleted + const existing = await db.query.lancamentos.findMany({ + columns: { + id: true, + name: true, + pagadorId: true, + amount: true, + transactionType: true, + paymentMethod: true, + condition: true, + purchaseDate: true, + period: true, + note: true, + }, + where: and( + inArray(lancamentos.id, data.ids), + eq(lancamentos.userId, user.id) + ), + }); + + if (existing.length === 0) { + return { success: false, error: "Nenhum lançamento encontrado." }; + } + + // Delete all lancamentos + await db + .delete(lancamentos) + .where( + and(inArray(lancamentos.id, data.ids), eq(lancamentos.userId, user.id)) + ); + + // Send notifications + const notificationData = existing + .filter((item): item is typeof item & { pagadorId: NonNullable } => + Boolean(item.pagadorId) + ) + .map((item) => ({ + pagadorId: item.pagadorId, + name: item.name ?? null, + amount: item.amount ?? null, + transactionType: item.transactionType ?? null, + paymentMethod: item.paymentMethod ?? null, + condition: item.condition ?? null, + purchaseDate: item.purchaseDate ?? null, + period: item.period ?? null, + note: item.note ?? null, + })); + + if (notificationData.length > 0) { + const notificationEntries = buildEntriesByPagador(notificationData); + + await sendPagadorAutoEmails({ + userLabel: resolveUserLabel(user), + action: "deleted", + entriesByPagador: notificationEntries, + }); + } + + revalidate(); + + const count = existing.length; + return { + success: true, + message: `${count} ${ + count === 1 ? "lançamento removido" : "lançamentos removidos" + } com sucesso.`, + }; + } catch (error) { + return handleActionError(error); + } +} + +// Get unique establishment names from the last 3 months +export async function getRecentEstablishmentsAction(): Promise { + try { + const user = await getUser(); + + // Calculate date 3 months ago + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + // Fetch establishment names from the last 3 months + const results = await db + .select({ name: lancamentos.name }) + .from(lancamentos) + .where( + and( + eq(lancamentos.userId, user.id), + gte(lancamentos.purchaseDate, threeMonthsAgo) + ) + ) + .orderBy(desc(lancamentos.purchaseDate)); + + // Remove duplicates and filter empty names + const uniqueNames = Array.from( + new Set( + results + .map((r) => r.name) + .filter( + (name): name is string => + name != null && + name.trim().length > 0 && + !name.toLowerCase().startsWith("pagamento fatura") + ) + ) + ); + + // Return top 50 most recent unique establishments + return uniqueNames.slice(0, 100); + } catch (error) { + console.error("Error fetching recent establishments:", error); + return []; + } +} diff --git a/app/(dashboard)/lancamentos/anticipation-actions.ts b/app/(dashboard)/lancamentos/anticipation-actions.ts new file mode 100644 index 0000000..e9dc201 --- /dev/null +++ b/app/(dashboard)/lancamentos/anticipation-actions.ts @@ -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> { + 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 { + 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> { + 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 { + 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> { + 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); + } +} diff --git a/app/(dashboard)/lancamentos/data.ts b/app/(dashboard)/lancamentos/data.ts new file mode 100644 index 0000000..d37d21b --- /dev/null +++ b/app/(dashboard)/lancamentos/data.ts @@ -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; +} diff --git a/app/(dashboard)/lancamentos/layout.tsx b/app/(dashboard)/lancamentos/layout.tsx new file mode 100644 index 0000000..5b7c7bd --- /dev/null +++ b/app/(dashboard)/lancamentos/layout.tsx @@ -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 ( +
+ } + 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} +
+ ); +} diff --git a/app/(dashboard)/lancamentos/loading.tsx b/app/(dashboard)/lancamentos/loading.tsx new file mode 100644 index 0000000..8d6aab8 --- /dev/null +++ b/app/(dashboard)/lancamentos/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ +
+ {/* Header com título e botão */} +
+ + +
+ + {/* Filtros */} + + + {/* Tabela */} + +
+
+ ); +} diff --git a/app/(dashboard)/lancamentos/page.tsx b/app/(dashboard)/lancamentos/page.tsx new file mode 100644 index 0000000..1f2ede2 --- /dev/null +++ b/app/(dashboard)/lancamentos/page.tsx @@ -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; + +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 ( +
+ + +
+ ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..d9529eb --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -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>; +}>) { + 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 ( + + ({ + id: item.id, + name: item.name, + avatarUrl: item.avatarUrl, + canEdit: item.canEdit, + }))} + variant="inset" + /> + + +
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/app/(dashboard)/orcamentos/actions.ts b/app/(dashboard)/orcamentos/actions.ts new file mode 100644 index 0000000..49ec068 --- /dev/null +++ b/app/(dashboard)/orcamentos/actions.ts @@ -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; +type BudgetUpdateInput = z.infer; +type BudgetDeleteInput = z.infer; + +const ensureCategory = async (userId: string, categoriaId: string) => { + const category = await db.query.categorias.findFirst({ + columns: { + id: true, + type: true, + }, + where: and(eq(categorias.id, categoriaId), eq(categorias.userId, userId)), + }); + + 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 { + 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 { + 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 { + 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); + } +} diff --git a/app/(dashboard)/orcamentos/data.ts b/app/(dashboard)/orcamentos/data.ts new file mode 100644 index 0000000..a2347eb --- /dev/null +++ b/app/(dashboard)/orcamentos/data.ts @@ -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(); + + 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 }; +} diff --git a/app/(dashboard)/orcamentos/layout.tsx b/app/(dashboard)/orcamentos/layout.tsx new file mode 100644 index 0000000..c4061b5 --- /dev/null +++ b/app/(dashboard)/orcamentos/layout.tsx @@ -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 ( +
+ } + title="Orçamentos" + subtitle="Gerencie seus orçamentos mensais por categorias. Acompanhe o progresso do seu orçamento e faça ajustes conforme necessário." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/orcamentos/loading.tsx b/app/(dashboard)/orcamentos/loading.tsx new file mode 100644 index 0000000..45fc824 --- /dev/null +++ b/app/(dashboard)/orcamentos/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ +
+ {/* Header */} +
+
+ + +
+ +
+ + {/* Grid de cards de orçamentos */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Categoria com ícone */} +
+ +
+ + +
+
+ + {/* Valor orçado */} +
+ + +
+ + {/* Valor gasto */} +
+ + +
+ + {/* Barra de progresso */} +
+ + +
+ + {/* Botões de ação */} +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/orcamentos/page.tsx b/app/(dashboard)/orcamentos/page.tsx new file mode 100644 index 0000000..4ed9370 --- /dev/null +++ b/app/(dashboard)/orcamentos/page.tsx @@ -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>; + +type PageProps = { + searchParams?: PageSearchParams; +}; + +const getSingleParam = ( + params: Record | 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 ( +
+ + +
+ ); +} + diff --git a/app/(dashboard)/pagadores/[pagadorId]/actions.ts b/app/(dashboard)/pagadores/[pagadorId]/actions.ts new file mode 100644 index 0000000..a0b22b1 --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/actions.ts @@ -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, "'"); +}; + +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>; + historyData: Awaited>; + cardUsage: Awaited>; + boletoStats: Awaited>; + boletos: BoletoItem[]; + lancamentos: LancamentoRow[]; + parcelados: ParceladoItem[]; +}; + +const buildSectionHeading = (label: string) => + `

${label}

`; + +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 ` + + ${escapeHtml( + point.label + )} + +
+
+
+
+ ${formatCurrency( + point.despesas + )} +
+ + `; + }) + .join("") + : `Sem histórico suficiente.`; + + const cardUsageRows = + cardUsage.length > 0 + ? cardUsage + .map( + (item) => ` + + ${escapeHtml( + item.name + )} + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Sem gastos com cartão neste período.`; + + const boletoRows = + boletos.length > 0 + ? boletos + .map( + (item) => ` + + ${escapeHtml( + item.name + )} + ${ + item.dueDate ? formatDate(item.dueDate) : "—" + } + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Sem boletos neste período.`; + + const lancamentoRows = + lancamentos.length > 0 + ? lancamentos + .map( + (item) => ` + + ${formatDate( + item.purchaseDate + )} + ${ + escapeHtml(item.name) || "Sem descrição" + } + ${ + escapeHtml(item.condition) || "—" + } + ${ + escapeHtml(item.paymentMethod) || "—" + } + ${formatCurrency( + item.amount + )} + ` + ) + .join("") + : `Nenhum lançamento registrado no período.`; + + const parceladoRows = + parcelados.length > 0 + ? parcelados + .map( + (item) => ` + + ${formatDate( + item.purchaseDate + )} + ${ + escapeHtml(item.name) || "Sem descrição" + } + ${ + item.currentInstallment + }/${item.installmentCount} + ${formatCurrency( + item.installmentAmount + )} + ${formatCurrency( + item.totalAmount + )} + ` + ) + .join("") + : `Nenhum lançamento parcelado neste período.`; + + return ` +
+ + Resumo mensal e detalhes de gastos por cartão, boletos e lançamentos. + + +
+

Resumo Financeiro

+

${escapeHtml( + periodLabel + )}

+
+ + +
+ +

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

+ + + ${buildSectionHeading("💰 Totais do mês")} + + + + + + + + + + + + + + + + + + + +
Total gasto + ${formatCurrency( + monthlyBreakdown.totalExpenses + )} +
💳 Cartões${formatCurrency( + monthlyBreakdown.paymentSplits.card + )}
📄 Boletos${formatCurrency( + monthlyBreakdown.paymentSplits.boleto + )}
⚡ Pix/Débito/Dinheiro${formatCurrency( + monthlyBreakdown.paymentSplits.instant + )}
+ + + ${buildSectionHeading("📊 Evolução das Despesas (6 meses)")} + + + + + + + + ${historyRows} +
PeríodoValor
+ + + ${buildSectionHeading("💳 Gastos com Cartões")} + + + + +
+ + + + + +
Total + ${formatCurrency( + monthlyBreakdown.paymentSplits.card + )} +
+
+ + + + + + + + ${cardUsageRows} +
CartãoValor
+ + + ${buildSectionHeading("📄 Boletos")} + + + + +
+ + + + + +
Total + ${formatCurrency( + boletoStats.totalAmount + )} +
+
+ + + + + + + + + ${boletoRows} +
DescriçãoVencimentoValor
+ + + ${buildSectionHeading("📝 Lançamentos do Mês")} + + + + + + + + + + + ${lancamentoRows} +
DataDescriçãoCondiçãoPagamentoValor
+ + + ${buildSectionHeading("💳 Lançamentos Parcelados")} + + + + + + + + + + + ${parceladoRows} +
DataDescriçãoParcelaValor ParcelaTotal
+ + +
+
+ + +

+ Este e-mail foi enviado automaticamente pelo OpenSheets. +

+
+ + `; +}; + +export async function sendPagadorSummaryAction( + input: z.infer +): Promise { + 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 "; + + 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.", + }; + } +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/data.ts b/app/(dashboard)/pagadores/[pagadorId]/data.ts new file mode 100644 index 0000000..44ad5ac --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/data.ts @@ -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 { + 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; +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/loading.tsx b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx new file mode 100644 index 0000000..555c0dc --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/loading.tsx @@ -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 ( +
+ {/* Month Picker placeholder */} +
+ + {/* Info do Pagador (sempre visível) */} +
+
+ {/* Avatar */} + + +
+ {/* Nome + Badge */} +
+ + +
+ + {/* Email */} + + + {/* Status */} +
+ + +
+
+ + {/* Botões de ação */} +
+ + +
+
+
+ + {/* Tabs */} +
+
+ + +
+ + {/* Conteúdo da aba Visão Geral (grid de cards) */} +
+ {/* Card de resumo mensal */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Outros cards */} + {Array.from({ length: 4 }).map((_, i) => ( +
+
+ + +
+
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/pagadores/[pagadorId]/page.tsx b/app/(dashboard)/pagadores/[pagadorId]/page.tsx new file mode 100644 index 0000000..d0f2d4d --- /dev/null +++ b/app/(dashboard)/pagadores/[pagadorId]/page.tsx @@ -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; + +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; + +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 + > | 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 ( +
+ + + + + Perfil + Painel + Lançamentos + + + +
+ +
+ {canEdit && pagadorData.shareCode ? ( + + ) : null} +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+
+
+
+ ); +} + +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(); + const cartaoOptionsMap = new Map(); + const categoriaOptionsMap = new Map(); + + items.forEach((item) => { + if (item.contaId && !contaOptionsMap.has(item.contaId)) { + contaOptionsMap.set(item.contaId, { + value: item.contaId, + label: normalizeOptionLabel(item.contaName, "Conta sem nome"), + slug: item.contaId, + }); + } + if (item.cartaoId && !cartaoOptionsMap.has(item.cartaoId)) { + cartaoOptionsMap.set(item.cartaoId, { + value: item.cartaoId, + label: normalizeOptionLabel(item.cartaoName, "Cartão sem nome"), + slug: item.cartaoId, + }); + } + if (item.categoriaId && !categoriaOptionsMap.has(item.categoriaId)) { + categoriaOptionsMap.set(item.categoriaId, { + value: item.categoriaId, + label: normalizeOptionLabel(item.categoriaName, "Categoria"), + slug: item.categoriaId, + }); + } + }); + + 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, + }; +} diff --git a/app/(dashboard)/pagadores/actions.ts b/app/(dashboard)/pagadores/actions.ts new file mode 100644 index 0000000..93e2999 --- /dev/null +++ b/app/(dashboard)/pagadores/actions.ts @@ -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; +type UpdateInput = z.infer; +type DeleteInput = z.infer; +type ShareDeleteInput = z.infer; +type ShareCodeJoinInput = z.infer; +type ShareCodeRegenerateInput = z.infer; + +const revalidate = () => revalidateForEntity("pagadores"); + +const generateShareCode = () => { + // base64url já retorna apenas [a-zA-Z0-9_-] + // 18 bytes = 24 caracteres em base64 + return randomBytes(18).toString("base64url").slice(0, 24); +}; + +export async function createPagadorAction( + input: CreateInput +): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/app/(dashboard)/pagadores/layout.tsx b/app/(dashboard)/pagadores/layout.tsx new file mode 100644 index 0000000..619824b --- /dev/null +++ b/app/(dashboard)/pagadores/layout.tsx @@ -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 ( +
+ } + title="Pagadores" + subtitle="Gerencie as pessoas ou entidades responsáveis pelos pagamentos." + /> + {children} +
+ ); +} diff --git a/app/(dashboard)/pagadores/loading.tsx b/app/(dashboard)/pagadores/loading.tsx new file mode 100644 index 0000000..85fa4b5 --- /dev/null +++ b/app/(dashboard)/pagadores/loading.tsx @@ -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 ( +
+
+ {/* Input de código de compartilhamento */} +
+ +
+ + +
+
+ + {/* Grid de cards de pagadores */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ {/* Avatar + Nome + Badge */} +
+ +
+ + +
+ {i === 0 && ( + + )} +
+ + {/* Email */} + + + {/* Status */} +
+ + +
+ + {/* Botões de ação */} +
+ + + +
+
+ ))} +
+
+
+ ); +} diff --git a/app/(dashboard)/pagadores/page.tsx b/app/(dashboard)/pagadores/page.tsx new file mode 100644 index 0000000..7ff25b9 --- /dev/null +++ b/app/(dashboard)/pagadores/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/app/(landing-page)/page.tsx b/app/(landing-page)/page.tsx new file mode 100644 index 0000000..5c8d9de --- /dev/null +++ b/app/(landing-page)/page.tsx @@ -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 ( +
+ {/* Navigation */} +
+
+
+ +
+ +
+
+ + {/* Hero Section */} +
+
+
+ + + Controle Financeiro Inteligente + + +

+ Gerencie suas finanças + com simplicidade +

+ +

+ Organize seus gastos, acompanhe receitas, gerencie cartões de + crédito e tome decisões financeiras mais inteligentes. Tudo em um + só lugar. +

+ +
+ + + + + + +
+ +
+
+ + Dados Seguros +
+
+ + Modo Privacidade +
+
+ + 100% Responsivo +
+
+
+
+
+ + {/* Features Section */} +
+
+
+
+ + Funcionalidades + +

+ Tudo que você precisa para gerenciar suas finanças +

+

+ Ferramentas poderosas e intuitivas para controle financeiro + completo +

+
+ +
+ + +
+
+ +
+
+

+ Lançamentos +

+

+ Registre receitas e despesas com categorização + automática e controle detalhado de pagadores e contas. +

+
+
+
+
+ + + +
+
+ +
+
+

+ Cartões de Crédito +

+

+ Gerencie múltiplos cartões, acompanhe faturas, limites e + nunca perca o controle dos gastos. +

+
+
+
+
+ + + +
+
+ +
+
+

Categorias

+

+ Organize suas transações em categorias personalizadas e + visualize onde seu dinheiro está indo. +

+
+
+
+
+ + + +
+
+ +
+
+

Orçamentos

+

+ Defina limites de gastos por categoria e receba alertas + para manter suas finanças no caminho certo. +

+
+
+
+
+ + + +
+
+ +
+
+

Insights

+

+ Análise detalhada de padrões de gastos com gráficos e + relatórios para decisões mais inteligentes. +

+
+
+
+
+ + + +
+
+ +
+
+

Calendário

+

+ Visualize suas transações em calendário mensal e nunca + perca prazos importantes. +

+
+
+
+
+
+
+
+
+ + {/* Benefits Section */} +
+
+
+
+
+ + Vantagens + +

+ Controle financeiro descomplicado +

+
+
+
+ +
+
+

+ Segurança em Primeiro Lugar +

+

+ Seus dados financeiros são criptografados e armazenados + com os mais altos padrões de segurança. +

+
+
+ +
+
+ +
+
+

Economize Tempo

+

+ Interface intuitiva que permite registrar transações em + segundos e acompanhar tudo de forma visual. +

+
+
+ +
+
+ +
+
+

+ Alertas Inteligentes +

+

+ Receba notificações sobre vencimentos, limites de + orçamento e padrões incomuns de gastos. +

+
+
+ +
+
+ +
+
+

Modo Privacidade

+

+ Oculte valores sensíveis com um clique para visualizar + suas finanças em qualquer lugar com discrição. +

+
+
+
+
+ +
+ + +
+ +
+

+ Visualização Clara +

+

+ Gráficos interativos e dashboards personalizáveis + mostram sua situação financeira de forma clara e + objetiva. +

+
+
+
+
+ + + +
+ +
+

+ Acesso em Qualquer Lugar +

+

+ Design responsivo que funciona perfeitamente em + desktop, tablet e smartphone. Suas finanças sempre à + mão. +

+
+
+
+
+ + + +
+ +
+

+ Privacidade Garantida +

+

+ Seus dados são seus. Sem compartilhamento com + terceiros, sem anúncios, sem surpresas. +

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

+ Pronto para transformar suas finanças? +

+

+ Comece agora mesmo a organizar seu dinheiro de forma inteligente. + É grátis e leva menos de um minuto. +

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

+ Gerencie suas finanças pessoais com simplicidade e segurança. +

+
+ +
+

Produto

+
    +
  • + + Funcionalidades + +
  • +
  • + + Preços + +
  • +
  • + + Segurança + +
  • +
+
+ +
+

Recursos

+
    +
  • + + Blog + +
  • +
  • + + Ajuda + +
  • +
  • + + Tutoriais + +
  • +
+
+ +
+

Legal

+
    +
  • + + Privacidade + +
  • +
  • + + Termos de Uso + +
  • +
  • + + Cookies + +
  • +
+
+
+ +
+

+ © {new Date().getFullYear()} OpenSheets. Todos os direitos + reservados. +

+
+ + Seus dados são protegidos e criptografados +
+
+
+
+
+ ); +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..b2c1b51 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth/config"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..ccdf9d5 --- /dev/null +++ b/app/api/health/route.ts @@ -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 } + ); + } +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..acbf006 --- /dev/null +++ b/app/error.tsx @@ -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 ( +
+ + + + + + Algo deu errado + + Ocorreu um problema inesperado. Por favor, tente novamente ou volte + para o dashboard. + + + +
+ + +
+
+
+
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..b379076 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8ed9b70 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f4d7dd1 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + {children} + + + + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9bbd750 --- /dev/null +++ b/app/not-found.tsx @@ -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 ( +
+ + + + + + Página não encontrada + + A página que você está procurando não existe ou foi movida. + + + + + + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..13a92f1 --- /dev/null +++ b/components.json @@ -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" + } +} diff --git a/components/ajustes/delete-account-form.tsx b/components/ajustes/delete-account-form.tsx new file mode 100644 index 0000000..6d87be7 --- /dev/null +++ b/components/ajustes/delete-account-form.tsx @@ -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 ( + <> +
+
+ +
+

+ Remoção definitiva de conta +

+

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

+
+
+ +
    +
  • Lançamentos, anexos e notas
  • +
  • Contas, cartões, orçamentos e categorias
  • +
  • Pagadores (incluindo o pagador padrão)
  • +
  • Preferências e configurações
  • +
+ + +
+ + + { + if (isPending) e.preventDefault(); + }} + onPointerDownOutside={(e) => { + if (isPending) e.preventDefault(); + }} + > + + Você tem certeza? + + Essa ação não pode ser desfeita. Isso irá deletar permanentemente + sua conta e remover seus dados de nossos servidores. + + + +
+
+ + setConfirmation(e.target.value)} + disabled={isPending} + placeholder="DELETAR" + autoComplete="off" + /> +
+
+ + + + + +
+
+ + ); +} diff --git a/components/ajustes/update-email-form.tsx b/components/ajustes/update-email-form.tsx new file mode 100644 index 0000000..ed862f1 --- /dev/null +++ b/components/ajustes/update-email-form.tsx @@ -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 ( +
+
+ + setNewEmail(e.target.value)} + disabled={isPending} + placeholder={currentEmail} + required + /> +
+ +
+ + setConfirmEmail(e.target.value)} + disabled={isPending} + placeholder="repita o e-mail" + required + /> +
+ + +
+ ); +} diff --git a/components/ajustes/update-name-form.tsx b/components/ajustes/update-name-form.tsx new file mode 100644 index 0000000..4fac3dd --- /dev/null +++ b/components/ajustes/update-name-form.tsx @@ -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 ( +
+
+ + setFirstName(e.target.value)} + disabled={isPending} + required + /> +
+ +
+ + setLastName(e.target.value)} + disabled={isPending} + required + /> +
+ + +
+ ); +} diff --git a/components/ajustes/update-password-form.tsx b/components/ajustes/update-password-form.tsx new file mode 100644 index 0000000..0f3d361 --- /dev/null +++ b/components/ajustes/update-password-form.tsx @@ -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 ( +
+
+ +
+ setNewPassword(e.target.value)} + disabled={isPending} + placeholder="Mínimo de 6 caracteres" + required + minLength={6} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + disabled={isPending} + placeholder="Repita a senha" + required + minLength={6} + /> + +
+
+ + +
+ ); +} diff --git a/components/animated-theme-toggler.tsx b/components/animated-theme-toggler.tsx new file mode 100644 index 0000000..39a6da2 --- /dev/null +++ b/components/animated-theme-toggler.tsx @@ -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(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 ( + + + + + + {isDark ? "Tema claro" : "Tema escuro"} + + + ); +}; diff --git a/components/anotacoes/note-card.tsx b/components/anotacoes/note-card.tsx new file mode 100644 index 0000000..f561ae0 --- /dev/null +++ b/components/anotacoes/note-card.tsx @@ -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: , + onClick: onEdit, + variant: "default" as const, + }, + { + label: "detalhes", + icon: , + onClick: onDetails, + variant: "default" as const, + }, + { + label: "remover", + icon: , + onClick: onRemove, + variant: "destructive" as const, + }, + ].filter((action) => typeof action.onClick === "function"); + + return ( + + +
+
+

+ {displayTitle} +

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

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

+ )} +
+ ) : ( +

+ {note.description} +

+ )} +
+ + {actions.length > 0 ? ( + + {actions.map(({ label, icon, onClick, variant }) => ( + + ))} + + ) : null} +
+ ); +} diff --git a/components/anotacoes/note-details-dialog.tsx b/components/anotacoes/note-details-dialog.tsx new file mode 100644 index 0000000..fc60994 --- /dev/null +++ b/components/anotacoes/note-details-dialog.tsx @@ -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 ( + + + + + {displayTitle} + {isTask && ( + + {completedCount}/{totalCount} + + )} + + {formattedDate} + + + {isTask ? ( +
+ {tasks.map((task) => ( +
+
+ {task.completed && ( + + )} +
+ + {task.text} + +
+ ))} +
+ ) : ( +
+ {note.description} +
+ )} + + + + + + +
+
+ ); +} diff --git a/components/anotacoes/note-dialog.tsx b/components/anotacoes/note-dialog.tsx new file mode 100644 index 0000000..4b0ef49 --- /dev/null +++ b/components/anotacoes/note-dialog.tsx @@ -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(null); + const [newTaskText, setNewTaskText] = useState(""); + + const titleRef = useRef(null); + const descRef = useRef(null); + const newTaskRef = useRef(null); + + // Use controlled state hook for dialog open state + const [dialogOpen, setDialogOpen] = useControlledState( + open, + false, + onOpenChange + ); + + const initialState = buildInitialValues(note); + + // Use form state hook for form management + const { formState, updateField, setFormState } = + useFormState(initialState); + + useEffect(() => { + if (dialogOpen) { + setFormState(buildInitialValues(note)); + setErrorMessage(null); + setNewTaskText(""); + requestAnimationFrame(() => titleRef.current?.focus()); + } + }, [dialogOpen, note, setFormState]); + + 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) => { + 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 ( + + {trigger ? {trigger} : null} + + + {title} + {description} + + +
+ {/* Seletor de Tipo - apenas no modo de criação */} + {mode === "create" && ( +
+ + + updateField("type", value as "nota" | "tarefa") + } + disabled={isPending} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+
+ )} + + {/* Título */} +
+ + updateField("title", e.target.value)} + placeholder={ + isNote ? "Ex.: Revisar metas do mês" : "Ex.: Tarefas da semana" + } + maxLength={MAX_TITLE} + disabled={isPending} + aria-describedby="note-title-help" + required + /> +

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

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