Initial commit: LexMind - Plataforma Jurídica Inteligente
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
45931
MANUAL.pdf
Normal file
45931
MANUAL.pdf
Normal file
File diff suppressed because one or more lines are too long
212
SECURITY-AUDIT.md
Normal file
212
SECURITY-AUDIT.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# LexMind Security Audit Report
|
||||
**Date:** 2026-02-01
|
||||
**Auditor:** Automated Security Audit
|
||||
**Status:** ✅ COMPLETED
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
Comprehensive security audit of the LexMind application (Next.js 16 + Prisma + PostgreSQL + NextAuth + Stripe). Found and fixed **12 vulnerabilities** across 7 categories. Zero npm vulnerabilities remain.
|
||||
|
||||
---
|
||||
|
||||
## 1. NPM Vulnerabilities
|
||||
**Status:** ✅ FIXED
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| 21 high severity | 0 vulnerabilities |
|
||||
|
||||
- **Root cause:** `fast-xml-parser` 5.2.5 (via AWS SDK) had RangeError DoS bug
|
||||
- **Fix:** Added npm override for `fast-xml-parser@5.3.4` in package.json
|
||||
|
||||
---
|
||||
|
||||
## 2. SQL Injection / Prisma
|
||||
**Status:** ✅ CLEAN
|
||||
|
||||
- No `$queryRaw` or `$executeRaw` usage found
|
||||
- All database access uses Prisma's parameterized queries
|
||||
- No raw SQL anywhere in the codebase
|
||||
|
||||
---
|
||||
|
||||
## 3. XSS (Cross-Site Scripting)
|
||||
**Status:** ✅ CLEAN
|
||||
|
||||
- No `dangerouslySetInnerHTML` or `innerHTML` usage found
|
||||
- React's default escaping protects against XSS
|
||||
- Added Content-Security-Policy header (see Section 9)
|
||||
|
||||
---
|
||||
|
||||
## 4. CSRF Protection
|
||||
**Status:** ✅ VERIFIED
|
||||
|
||||
- NextAuth CSRF tokens working correctly
|
||||
- Cookies use `__Host-` prefix with `HttpOnly; Secure; SameSite=Lax`
|
||||
- All mutating API routes require authenticated session
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication & Authorization
|
||||
**Status:** ✅ FIXED (2 critical issues)
|
||||
|
||||
### 🔴 CRITICAL: Unauthenticated Checkout Route
|
||||
- **File:** `/api/checkout/route.ts`
|
||||
- **Issue:** No `getServerSession` check — anyone could create Stripe checkout sessions
|
||||
- **Fix:** Added authentication check, uses session email instead of user-provided email
|
||||
|
||||
### 🔴 CRITICAL: Unauthenticated DOCX Export Route
|
||||
- **File:** `/api/export/docx/route.ts`
|
||||
- **Issue:** No authentication — anyone could generate DOCX documents
|
||||
- **Fix:** Added `getServerSession` check
|
||||
|
||||
### Auth Coverage (all routes verified):
|
||||
| Route | Auth | IDOR Protected |
|
||||
|-------|------|----------------|
|
||||
| /api/admin/stats | ✅ ADMIN check | N/A |
|
||||
| /api/analise-processo | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/analise-processo/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/auditoria | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/auditoria/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/chat | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/chat/[chatId] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/checkout | ✅ **FIXED** | N/A |
|
||||
| /api/documents | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/documents/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/documents/generate | ✅ session.user.id | N/A |
|
||||
| /api/export/docx | ✅ **FIXED** | N/A |
|
||||
| /api/jurisprudencia | ✅ session.user.id | N/A (public data) |
|
||||
| /api/jurisprudencia/search | ✅ session.user.id | N/A (public data) |
|
||||
| /api/keys | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/keys/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/prazos | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/prazos/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/register | N/A (public) | N/A |
|
||||
| /api/stripe/checkout | ✅ session.user | ✅ |
|
||||
| /api/stripe/portal | ✅ session.user | ✅ |
|
||||
| /api/stripe/webhook | N/A (Stripe sig) | ✅ signature verified |
|
||||
| /api/templates | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/uploads | ✅ session.user.id | ✅ userId filter |
|
||||
| /api/uploads/[id] | ✅ session.user.id | ✅ userId filter |
|
||||
|
||||
---
|
||||
|
||||
## 6. Rate Limiting
|
||||
**Status:** ✅ VERIFIED
|
||||
|
||||
Nginx rate limiting active:
|
||||
- Auth routes: 5 req/min (`zone=auth`)
|
||||
- API routes: 20 req/sec (`zone=api`)
|
||||
- General: 30 req/sec (`zone=general`)
|
||||
- Connection limit: 20 per IP (`conn_limit`)
|
||||
- Scanner/bot blocking via User-Agent filter
|
||||
|
||||
---
|
||||
|
||||
## 7. Input Validation
|
||||
**Status:** ✅ FIXED (5 improvements)
|
||||
|
||||
- **Created:** `src/lib/validate.ts` with sanitization utilities
|
||||
- **Register route:** Added input length limits for all fields
|
||||
- **Chat route:** Added 10,000 char message limit
|
||||
- **Auditoria route:** Added title (500) and content (100,000) limits
|
||||
- **Prazos route:** Added title (500) and description (5,000) limits
|
||||
- **Pagination:** Bounded page/limit params in uploads and jurisprudencia routes
|
||||
- **Uploads:** Added server-side file extension validation (defense in depth)
|
||||
|
||||
---
|
||||
|
||||
## 8. Sensitive Data Exposure
|
||||
**Status:** ✅ FIXED
|
||||
|
||||
- **Created `.gitignore`** — `.env` was not being excluded (no `.gitignore` existed!)
|
||||
- **Stripe error messages:** Stopped leaking `error.message` to client in checkout/portal routes
|
||||
- **API responses:** Verified no password hashes or internal IDs are exposed
|
||||
- **API keys:** Properly hashed (SHA-256), only shown once on creation, masked in listings
|
||||
- **NEXT_PUBLIC vars:** Only publishable Stripe key and app URL (safe)
|
||||
- **Error handling:** All routes return generic error messages, details logged server-side
|
||||
|
||||
---
|
||||
|
||||
## 9. Security Headers
|
||||
**Status:** ✅ FIXED (2 new headers added)
|
||||
|
||||
### Headers now active:
|
||||
| Header | Value | Status |
|
||||
|--------|-------|--------|
|
||||
| X-Frame-Options | SAMEORIGIN | ✅ existing |
|
||||
| X-Content-Type-Options | nosniff | ✅ existing |
|
||||
| X-XSS-Protection | 1; mode=block | ✅ existing |
|
||||
| Referrer-Policy | strict-origin-when-cross-origin | ✅ existing |
|
||||
| Strict-Transport-Security | max-age=31536000; includeSubDomains | ✅ existing |
|
||||
| Content-Security-Policy | Full CSP policy | ✅ **ADDED** |
|
||||
| Permissions-Policy | camera=(), microphone=(), geolocation=() | ✅ **ADDED** |
|
||||
| server_tokens | off | ✅ existing |
|
||||
|
||||
---
|
||||
|
||||
## 10. Database Security
|
||||
**Status:** ✅ VERIFIED
|
||||
|
||||
- PostgreSQL listens only on localhost (default, `listen_addresses = 'localhost'`)
|
||||
- `pg_hba.conf` uses `scram-sha-256` for TCP connections
|
||||
- Local connections use `peer` authentication
|
||||
- No remote access configured
|
||||
|
||||
---
|
||||
|
||||
## 11. File Upload Security
|
||||
**Status:** ✅ VERIFIED + IMPROVED
|
||||
|
||||
- MIME type whitelist: PDF, DOC, DOCX, TXT only
|
||||
- **Added:** File extension validation (defense in depth)
|
||||
- Max size: 50MB (enforced both in app and nginx `client_max_body_size`)
|
||||
- Storage limits per plan (1GB-20GB)
|
||||
- File paths sanitized via `buildKey()` — strips all special chars
|
||||
- No path traversal possible (`../` becomes `___`)
|
||||
- Files stored as `private` ACL on DigitalOcean Spaces
|
||||
- Access via signed URLs (15 min expiry)
|
||||
|
||||
---
|
||||
|
||||
## 12. Session Security
|
||||
**Status:** ✅ IMPROVED
|
||||
|
||||
- Cookies: `__Host-` prefix, `HttpOnly`, `Secure`, `SameSite=Lax`
|
||||
- **Reduced session maxAge from 30 days to 7 days** (more appropriate for legal app)
|
||||
- JWT strategy with strong NEXTAUTH_SECRET (44 chars, base64)
|
||||
- CSRF token verified on all auth requests
|
||||
|
||||
---
|
||||
|
||||
## 13. Other Fixes
|
||||
|
||||
### Duplicate Webhook Route Removed
|
||||
- **Deleted:** `/api/webhook/stripe/route.ts` (incomplete, only logged events, no DB updates)
|
||||
- **Active:** `/api/stripe/webhook/route.ts` (fully functional with DB updates)
|
||||
|
||||
### Next.js Middleware Added
|
||||
- **Created:** `src/middleware.ts` — adds security headers at application level as backup
|
||||
|
||||
---
|
||||
|
||||
## 14. Remaining Notes (Low Risk)
|
||||
|
||||
| Item | Risk | Notes |
|
||||
|------|------|-------|
|
||||
| `typescript: { ignoreBuildErrors: true }` in next.config | Low | Could hide type errors; recommend fixing eventually |
|
||||
| AWS SDK DoS vuln was in XML parsing | Info | Fixed via override, but only exploitable if attacker controls S3 responses |
|
||||
| File upload MIME check trusts client header | Low | Mitigated by extension whitelist + private storage |
|
||||
| No email verification on registration | Medium | Users can register with unverified emails |
|
||||
|
||||
---
|
||||
|
||||
## Deployment Status
|
||||
- ✅ All fixes applied
|
||||
- ✅ `npm audit`: 0 vulnerabilities
|
||||
- ✅ Build successful
|
||||
- ✅ PM2 restarted
|
||||
- ✅ Nginx reloaded with new headers
|
||||
- ✅ Application verified working
|
||||
118
docs/INTEGRACAO-DIARIOS.md
Normal file
118
docs/INTEGRACAO-DIARIOS.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Integração com Diários Oficiais - LexMind
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O LexMind agora possui integração real com a API DataJud do CNJ para buscar publicações processuais automaticamente.
|
||||
|
||||
## Fontes de Dados
|
||||
|
||||
### 1. API DataJud (CNJ) - Fonte Principal
|
||||
- **URL Base:** https://api-publica.datajud.cnj.jus.br/
|
||||
- **Autenticação:** API Key pública do CNJ
|
||||
- **Tribunais Suportados:** Todos os TJs, TRFs, STJ, STF, TST
|
||||
- **Dados Retornados:** Movimentações processuais (publicações, intimações, citações, etc.)
|
||||
|
||||
### 2. DJe TJSP (Backup - não implementado)
|
||||
- Pode ser adicionado via scraping se necessário
|
||||
|
||||
## Arquivos Criados/Modificados
|
||||
|
||||
```
|
||||
src/lib/
|
||||
├── diarios-service.ts # Service principal de integração
|
||||
├── publicacoes-service.ts # Cálculo de prazos e tipos
|
||||
|
||||
src/app/api/publicacoes/buscar/
|
||||
├── route.ts # API atualizada para busca real
|
||||
|
||||
scripts/
|
||||
├── buscar-publicacoes.ts # Script de busca diária (cron)
|
||||
├── testar-datajud.ts # Teste de conexão com API
|
||||
├── teste-integracao.ts # Teste completo do service
|
||||
├── teste-standalone.ts # Teste isolado
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### Busca Manual (via API)
|
||||
```bash
|
||||
# Buscar publicações de um processo específico
|
||||
curl -X POST https://lexmind.com.br/api/publicacoes/buscar \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: <session>" \
|
||||
-d '{"processoId": "cuid-do-processo"}'
|
||||
|
||||
# Buscar todos os processos ativos do usuário
|
||||
curl -X POST https://lexmind.com.br/api/publicacoes/buscar \
|
||||
-H "Cookie: <session>"
|
||||
```
|
||||
|
||||
### Busca Diária (Cron)
|
||||
```bash
|
||||
cd /var/www/lexmind
|
||||
npx ts-node scripts/buscar-publicacoes.ts
|
||||
```
|
||||
|
||||
### Testes
|
||||
```bash
|
||||
# Testar conexão com DataJud
|
||||
npx ts-node scripts/testar-datajud.ts
|
||||
|
||||
# Testar busca com processo real
|
||||
npx ts-node scripts/teste-standalone.ts
|
||||
```
|
||||
|
||||
## Tipos de Publicação Detectados
|
||||
|
||||
| Tipo | Código CNJ | Keywords |
|
||||
|------|------------|----------|
|
||||
| INTIMACAO | 12265, 12021 | intimação, fica intimado |
|
||||
| CITACAO | 14, 12037 | citação, fica citado |
|
||||
| SENTENCA | 22, 848 | sentença, julgo procedente |
|
||||
| ACORDAO | 217, 219 | acórdão, acordam os desembargadores |
|
||||
| DESPACHO | 11010, 11383 | despacho, determino |
|
||||
| PUBLICACAO | 92 | (genérico) |
|
||||
|
||||
## Cálculo de Prazos
|
||||
|
||||
- INTIMACAO/CITACAO/SENTENCA/ACORDAO: 15 dias úteis
|
||||
- DESPACHO: 5 dias úteis
|
||||
- OUTROS: 5 dias úteis
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
- Delay de 500ms entre requisições em lote
|
||||
- Busca processos dos últimos 30 dias por padrão
|
||||
|
||||
## Configuração do Cron
|
||||
|
||||
Para executar a busca diariamente às 7h:
|
||||
|
||||
```bash
|
||||
# Via crontab no servidor
|
||||
0 7 * * * cd /var/www/lexmind && /usr/bin/npx ts-node scripts/buscar-publicacoes.ts >> /var/log/lexmind-publicacoes.log 2>&1
|
||||
|
||||
# Ou via Clawdbot (remoto)
|
||||
clawdbot cron add "0 7 * * *" "ssh jarvis-do 'cd /var/www/lexmind && npx ts-node scripts/buscar-publicacoes.ts'"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API retorna erro 401
|
||||
- Verificar se a API Key está correta
|
||||
- A key pública do CNJ raramente muda
|
||||
|
||||
### Processo não encontrado
|
||||
- Verificar formato do número (20 dígitos sem formatação)
|
||||
- Verificar se o tribunal está correto
|
||||
- Alguns processos podem estar em sigilo
|
||||
|
||||
### Timeout na busca
|
||||
- Aumentar delay entre requisições
|
||||
- Verificar conectividade de rede
|
||||
|
||||
## Manutenção
|
||||
|
||||
- Monitorar logs de busca diária
|
||||
- Verificar se há novos códigos de movimentos no CNJ
|
||||
- Atualizar mapeamento de tribunais se necessário
|
||||
111
docs/PUBLICACOES.md
Normal file
111
docs/PUBLICACOES.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Módulo de Monitoramento de Publicações
|
||||
|
||||
## Visão Geral
|
||||
|
||||
Este módulo permite que advogados monitorem publicações nos Diários Oficiais (DJe, DOU, DOESP) relacionadas aos seus processos. O sistema calcula automaticamente prazos com base no tipo de publicação.
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
- **Cadastro de processos** para monitoramento
|
||||
- **Busca de publicações** (mock para MVP)
|
||||
- **Cálculo automático de prazos** com base no tipo de publicação
|
||||
- **Dashboard** com estatísticas e alertas
|
||||
- **Filtros** por tipo, processo, período, status de leitura
|
||||
- **Marcação de publicações** como lidas
|
||||
|
||||
## Tipos de Publicação e Prazos
|
||||
|
||||
| Tipo | Prazo Padrão |
|
||||
|------|--------------|
|
||||
| Intimação | 15 dias úteis |
|
||||
| Citação | 15 dias úteis |
|
||||
| Sentença | 15 dias úteis (recurso) |
|
||||
| Despacho | 5 dias úteis |
|
||||
| Acórdão | 15 dias úteis (embargos/recurso) |
|
||||
| Outros | 5 dias úteis |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Processos
|
||||
|
||||
- `GET /api/processos` - Lista processos monitorados
|
||||
- `POST /api/processos` - Cadastra novo processo
|
||||
- `GET /api/processos/[id]` - Detalhes de um processo
|
||||
- `PUT /api/processos/[id]` - Atualiza processo
|
||||
- `DELETE /api/processos/[id]` - Remove processo
|
||||
|
||||
### Publicações
|
||||
|
||||
- `GET /api/publicacoes` - Lista publicações (com filtros)
|
||||
- `PATCH /api/publicacoes/[id]/visualizar` - Marca como lida
|
||||
- `POST /api/publicacoes/buscar` - Busca novas publicações
|
||||
|
||||
## Configuração de Busca Automática (Cron Job)
|
||||
|
||||
### Opção 1: Cron do Sistema
|
||||
|
||||
```bash
|
||||
# Editar crontab
|
||||
crontab -e
|
||||
|
||||
# Adicionar linha para buscar diariamente às 8h
|
||||
0 8 * * * curl -X POST http://localhost:3000/api/publicacoes/buscar -H "Cookie: session=..." >> /var/log/lexmind-publicacoes.log 2>&1
|
||||
```
|
||||
|
||||
### Opção 2: Scheduler Externo
|
||||
|
||||
Use serviços como:
|
||||
- **cron-job.org** (gratuito)
|
||||
- **EasyCron**
|
||||
- **GitHub Actions** (scheduled workflow)
|
||||
|
||||
Configurar para fazer POST em `/api/publicacoes/buscar` diariamente.
|
||||
|
||||
## Integração com APIs Reais (Futuro)
|
||||
|
||||
Para substituir o mock por APIs reais dos tribunais:
|
||||
|
||||
1. Editar `src/lib/publicacoes-service.ts`
|
||||
2. Implementar `buscarPublicacoesTribunal(tribunal, numeroProcesso)`
|
||||
3. APIs sugeridas:
|
||||
- **Datajud** (CNJ) - https://datajud-wiki.cnj.jus.br/
|
||||
- **e-SAJ** (TJSP) - via scraping
|
||||
- **PJe** - via API onde disponível
|
||||
|
||||
## Schema do Banco
|
||||
|
||||
```prisma
|
||||
model ProcessoMonitorado {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
numeroProcesso String // Formato CNJ: 0000000-00.0000.0.00.0000
|
||||
tribunal String // Ex: TJSP, TRF3, STJ
|
||||
vara String?
|
||||
comarca String?
|
||||
parteAutora String?
|
||||
parteRe String?
|
||||
status ProcessoStatus @default(ATIVO)
|
||||
publicacoes Publicacao[]
|
||||
}
|
||||
|
||||
model Publicacao {
|
||||
id String @id @default(cuid())
|
||||
processoId String
|
||||
dataPublicacao DateTime
|
||||
diario String // Ex: DJe, DOU, DOESP
|
||||
conteudo String @db.Text
|
||||
tipo TipoPublicacao
|
||||
prazoCalculado DateTime?
|
||||
prazoTipo String?
|
||||
visualizado Boolean @default(false)
|
||||
}
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (MVP)
|
||||
- Cadastro e gerenciamento de processos
|
||||
- Dashboard de publicações
|
||||
- Busca mock para testes
|
||||
- Cálculo automático de prazos
|
||||
- Filtros e marcação de leitura
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
16
next.config.ts
Normal file
16
next.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
basePath: "/adv",
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
proxyClientMaxBodySize: '50mb',
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8799
package-lock.json
generated
Normal file
8799
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "lexmind",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"seed": "npx tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.893.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.893.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@stripe/stripe-js": "^8.7.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.5.1",
|
||||
"framer-motion": "^12.29.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"openai": "^6.17.0",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"prisma": "^6.19.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"stripe": "^20.3.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-xml-parser": "5.3.4"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
176
prisma/migrations/20260201184458_init/migration.sql
Normal file
176
prisma/migrations/20260201184458_init/migration.sql
Normal file
@@ -0,0 +1,176 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'ADVOGADO', 'FREE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'ENTERPRISE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentType" AS ENUM ('PETICAO_INICIAL', 'CONTESTACAO', 'APELACAO', 'RECURSO', 'CONTRATO', 'PARECER', 'IMPUGNACAO', 'HABEAS_CORPUS', 'MANDADO_SEGURANCA', 'OUTROS');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LegalArea" AS ENUM ('CIVIL', 'TRABALHISTA', 'PENAL', 'TRIBUTARIO', 'FAMILIA', 'EMPRESARIAL', 'CONSUMIDOR', 'ADMINISTRATIVO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentStatus" AS ENUM ('GENERATING', 'COMPLETED', 'ERROR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MessageRole" AS ENUM ('USER', 'ASSISTANT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'CANCELLED', 'EXPIRED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UsageType" AS ENUM ('DOCUMENT', 'CHAT', 'JURISPRUDENCIA');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'FREE',
|
||||
"plan" "Plan" NOT NULL DEFAULT 'FREE',
|
||||
"oabNumber" TEXT,
|
||||
"oabState" TEXT,
|
||||
"phone" TEXT,
|
||||
"avatar" TEXT,
|
||||
"credits" INTEGER NOT NULL DEFAULT 5,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "DocumentType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"wordCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"status" "DocumentStatus" NOT NULL DEFAULT 'GENERATING',
|
||||
"area" "LegalArea" NOT NULL,
|
||||
"tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Template" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"type" "DocumentType" NOT NULL,
|
||||
"area" "LegalArea" NOT NULL,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Jurisprudencia" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tribunal" TEXT NOT NULL,
|
||||
"numero" TEXT NOT NULL,
|
||||
"ementa" TEXT NOT NULL,
|
||||
"data" TEXT NOT NULL,
|
||||
"area" TEXT NOT NULL,
|
||||
"relator" TEXT NOT NULL,
|
||||
"orgaoJulgador" TEXT NOT NULL,
|
||||
"tags" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Jurisprudencia_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Chat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
"role" "MessageRole" NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Subscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"plan" "Plan" NOT NULL,
|
||||
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endDate" TIMESTAMP(3),
|
||||
"stripeId" TEXT,
|
||||
|
||||
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UsageLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" "UsageType" NOT NULL,
|
||||
"tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UsageLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Template" ADD CONSTRAINT "Template_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UsageLog" ADD CONSTRAINT "UsageLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrazoStatus" AS ENUM ('PENDENTE', 'CONCLUIDO', 'VENCIDO', 'CANCELADO');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrazoPriority" AS ENUM ('ALTA', 'MEDIA', 'BAIXA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditStatus" AS ENUM ('PENDING', 'ANALYZING', 'DONE', 'ERROR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Prazo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"processNumber" TEXT,
|
||||
"court" TEXT,
|
||||
"deadline" TIMESTAMP(3) NOT NULL,
|
||||
"alertDays" INTEGER NOT NULL DEFAULT 3,
|
||||
"status" "PrazoStatus" NOT NULL DEFAULT 'PENDENTE',
|
||||
"priority" "PrazoPriority" NOT NULL DEFAULT 'MEDIA',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Prazo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractAudit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"analysis" JSONB,
|
||||
"status" "AuditStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"riskScore" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ContractAudit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Prazo" ADD CONSTRAINT "Prazo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractAudit" ADD CONSTRAINT "ContractAudit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Plan" ADD VALUE 'STARTER';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "stripeCustomerId" TEXT,
|
||||
ADD COLUMN "stripePriceId" TEXT,
|
||||
ADD COLUMN "stripeSubscriptionId" TEXT;
|
||||
15
prisma/migrations/20260201210744_add_uploads/migration.sql
Normal file
15
prisma/migrations/20260201210744_add_uploads/migration.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Upload" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Upload" ADD CONSTRAINT "Upload_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProcessAnalysis" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"fileKey" TEXT NOT NULL,
|
||||
"fileSize" INTEGER NOT NULL,
|
||||
"extractedText" TEXT NOT NULL,
|
||||
"analysis" TEXT NOT NULL,
|
||||
"summary" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProcessAnalysis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProcessAnalysis" ADD CONSTRAINT "ProcessAnalysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
BIN
prisma/prisma/dev.db
Normal file
BIN
prisma/prisma/dev.db
Normal file
Binary file not shown.
71
prisma/schema-update.prisma
Normal file
71
prisma/schema-update.prisma
Normal file
@@ -0,0 +1,71 @@
|
||||
// ===== MONITORAMENTO DE PUBLICAÇÕES =====
|
||||
|
||||
model ProcessoMonitorado {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
numeroProcesso String // Ex: 0001234-56.2024.8.26.0100
|
||||
tribunal String // Ex: TJSP, TRF3, STJ
|
||||
vara String?
|
||||
comarca String?
|
||||
parteAutora String?
|
||||
parteRe String?
|
||||
status ProcessoStatus @default(ATIVO)
|
||||
|
||||
// Dados do processo (buscados da API DataJud)
|
||||
classe String?
|
||||
assunto String?
|
||||
dataAjuizamento DateTime?
|
||||
orgaoJulgador String?
|
||||
grau String?
|
||||
valorCausa Float?
|
||||
ultimaAtualizacao DateTime?
|
||||
dadosCompletos Json? // JSON com todos os dados brutos da API
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
publicacoes Publicacao[]
|
||||
andamentos Andamento[]
|
||||
}
|
||||
|
||||
model Andamento {
|
||||
id String @id @default(cuid())
|
||||
processoId String
|
||||
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
|
||||
codigo Int
|
||||
nome String
|
||||
dataHora DateTime
|
||||
complemento String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([processoId, codigo, dataHora])
|
||||
}
|
||||
|
||||
model Publicacao {
|
||||
id String @id @default(cuid())
|
||||
processoId String
|
||||
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
|
||||
dataPublicacao DateTime
|
||||
diario String // Ex: DJe, DOU, DOESP
|
||||
conteudo String @db.Text
|
||||
tipo TipoPublicacao
|
||||
prazoCalculado DateTime?
|
||||
prazoTipo String? // Ex: "15 dias úteis", "5 dias"
|
||||
visualizado Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum ProcessoStatus {
|
||||
ATIVO
|
||||
ARQUIVADO
|
||||
SUSPENSO
|
||||
}
|
||||
|
||||
enum TipoPublicacao {
|
||||
INTIMACAO
|
||||
CITACAO
|
||||
SENTENCA
|
||||
DESPACHO
|
||||
ACORDAO
|
||||
OUTROS
|
||||
}
|
||||
338
prisma/schema.prisma
Normal file
338
prisma/schema.prisma
Normal file
@@ -0,0 +1,338 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique
|
||||
password String
|
||||
role UserRole @default(FREE)
|
||||
plan Plan @default(FREE)
|
||||
oabNumber String?
|
||||
oabState String?
|
||||
phone String?
|
||||
avatar String?
|
||||
credits Int @default(5)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
stripeCustomerId String?
|
||||
stripePriceId String?
|
||||
stripeSubscriptionId String?
|
||||
apiKeys ApiKey[]
|
||||
chats Chat[]
|
||||
contractAudits ContractAudit[]
|
||||
documents Document[]
|
||||
prazos Prazo[]
|
||||
subscriptions Subscription[]
|
||||
templates Template[]
|
||||
usageLogs UsageLog[]
|
||||
uploads Upload[]
|
||||
processAnalyses ProcessAnalysis[]
|
||||
processosMonitorados ProcessoMonitorado[]
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
name String
|
||||
userId String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type DocumentType
|
||||
title String
|
||||
prompt String
|
||||
content String
|
||||
wordCount Int @default(0)
|
||||
status DocumentStatus @default(GENERATING)
|
||||
area LegalArea
|
||||
tokens Int @default(0)
|
||||
cost Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Template {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String
|
||||
type DocumentType
|
||||
area LegalArea
|
||||
prompt String
|
||||
isPublic Boolean @default(false)
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model Jurisprudencia {
|
||||
id String @id @default(cuid())
|
||||
tribunal String
|
||||
numero String
|
||||
ementa String
|
||||
data String
|
||||
area String
|
||||
relator String
|
||||
orgaoJulgador String
|
||||
tags String
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
chatId String
|
||||
role MessageRole
|
||||
content String
|
||||
tokens Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
plan Plan
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
startDate DateTime @default(now())
|
||||
endDate DateTime?
|
||||
stripeId String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model UsageLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type UsageType
|
||||
tokens Int @default(0)
|
||||
cost Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Prazo {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
description String?
|
||||
processNumber String?
|
||||
court String?
|
||||
deadline DateTime
|
||||
alertDays Int @default(3)
|
||||
status PrazoStatus @default(PENDENTE)
|
||||
priority PrazoPriority @default(MEDIA)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ContractAudit {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
content String
|
||||
analysis Json?
|
||||
status AuditStatus @default(PENDING)
|
||||
riskScore Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
|
||||
model Upload {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
filename String
|
||||
key String
|
||||
size Int
|
||||
mimeType String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
ADVOGADO
|
||||
FREE
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PRO
|
||||
ENTERPRISE
|
||||
STARTER
|
||||
}
|
||||
|
||||
enum DocumentType {
|
||||
PETICAO_INICIAL
|
||||
CONTESTACAO
|
||||
APELACAO
|
||||
RECURSO
|
||||
CONTRATO
|
||||
PARECER
|
||||
IMPUGNACAO
|
||||
HABEAS_CORPUS
|
||||
MANDADO_SEGURANCA
|
||||
OUTROS
|
||||
}
|
||||
|
||||
enum LegalArea {
|
||||
CIVIL
|
||||
TRABALHISTA
|
||||
PENAL
|
||||
TRIBUTARIO
|
||||
FAMILIA
|
||||
EMPRESARIAL
|
||||
CONSUMIDOR
|
||||
ADMINISTRATIVO
|
||||
}
|
||||
|
||||
enum DocumentStatus {
|
||||
GENERATING
|
||||
COMPLETED
|
||||
ERROR
|
||||
}
|
||||
|
||||
enum MessageRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
CANCELLED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum UsageType {
|
||||
DOCUMENT
|
||||
CHAT
|
||||
JURISPRUDENCIA
|
||||
}
|
||||
|
||||
enum PrazoStatus {
|
||||
PENDENTE
|
||||
CONCLUIDO
|
||||
VENCIDO
|
||||
CANCELADO
|
||||
}
|
||||
|
||||
enum PrazoPriority {
|
||||
ALTA
|
||||
MEDIA
|
||||
BAIXA
|
||||
}
|
||||
|
||||
enum AuditStatus {
|
||||
PENDING
|
||||
ANALYZING
|
||||
DONE
|
||||
ERROR
|
||||
}
|
||||
|
||||
model ProcessAnalysis {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
filename String
|
||||
fileKey String
|
||||
fileSize Int
|
||||
extractedText String @db.Text
|
||||
analysis String @db.Text
|
||||
summary String? @db.Text
|
||||
status String @default("PENDING")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ===== MONITORAMENTO DE PUBLICAÇÕES =====
|
||||
|
||||
model ProcessoMonitorado {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
numeroProcesso String // Ex: 0001234-56.2024.8.26.0100
|
||||
tribunal String // Ex: TJSP, TRF3, STJ
|
||||
vara String?
|
||||
comarca String?
|
||||
parteAutora String?
|
||||
parteRe String?
|
||||
status ProcessoStatus @default(ATIVO)
|
||||
|
||||
// Dados do processo (buscados da API DataJud)
|
||||
classe String?
|
||||
assunto String?
|
||||
dataAjuizamento DateTime?
|
||||
orgaoJulgador String?
|
||||
grau String?
|
||||
valorCausa Float?
|
||||
ultimaAtualizacao DateTime?
|
||||
dadosCompletos Json? // JSON com todos os dados brutos da API
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
publicacoes Publicacao[]
|
||||
andamentos Andamento[]
|
||||
}
|
||||
|
||||
model Publicacao {
|
||||
id String @id @default(cuid())
|
||||
processoId String
|
||||
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
|
||||
dataPublicacao DateTime
|
||||
diario String // Ex: DJe, DOU, DOESP
|
||||
conteudo String @db.Text
|
||||
tipo TipoPublicacao
|
||||
prazoCalculado DateTime?
|
||||
prazoTipo String? // Ex: "15 dias úteis", "5 dias"
|
||||
visualizado Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum ProcessoStatus {
|
||||
ATIVO
|
||||
ARQUIVADO
|
||||
SUSPENSO
|
||||
}
|
||||
|
||||
enum TipoPublicacao {
|
||||
INTIMACAO
|
||||
CITACAO
|
||||
SENTENCA
|
||||
DESPACHO
|
||||
ACORDAO
|
||||
OUTROS
|
||||
}
|
||||
|
||||
model Andamento {
|
||||
id String @id @default(cuid())
|
||||
processoId String
|
||||
processo ProcessoMonitorado @relation(fields: [processoId], references: [id], onDelete: Cascade)
|
||||
codigo Int
|
||||
nome String
|
||||
dataHora DateTime
|
||||
complemento String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([processoId, codigo, dataHora])
|
||||
}
|
||||
367
prisma/seed.ts
Normal file
367
prisma/seed.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding database...')
|
||||
|
||||
// ── Users ──
|
||||
const passwordHash = await bcrypt.hash('123456', 10)
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@juridico.ai' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Admin Sistema',
|
||||
email: 'admin@juridico.ai',
|
||||
password: passwordHash,
|
||||
role: 'ADMIN',
|
||||
plan: 'ENTERPRISE',
|
||||
credits: 9999,
|
||||
phone: '11999999999',
|
||||
},
|
||||
})
|
||||
|
||||
const advogadoPro = await prisma.user.upsert({
|
||||
where: { email: 'maria@advocacia.com' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Maria Silva',
|
||||
email: 'maria@advocacia.com',
|
||||
password: passwordHash,
|
||||
role: 'ADVOGADO',
|
||||
plan: 'PRO',
|
||||
oabNumber: '123456',
|
||||
oabState: 'SP',
|
||||
credits: 100,
|
||||
phone: '11988888888',
|
||||
},
|
||||
})
|
||||
|
||||
const advogadoFree = await prisma.user.upsert({
|
||||
where: { email: 'joao@email.com' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'João Santos',
|
||||
email: 'joao@email.com',
|
||||
password: passwordHash,
|
||||
role: 'ADVOGADO',
|
||||
plan: 'FREE',
|
||||
oabNumber: '654321',
|
||||
oabState: 'RJ',
|
||||
credits: 5,
|
||||
phone: '21977777777',
|
||||
},
|
||||
})
|
||||
|
||||
console.log('✅ Users created:', admin.name, advogadoPro.name, advogadoFree.name)
|
||||
|
||||
// ── Subscriptions ──
|
||||
await prisma.subscription.createMany({
|
||||
data: [
|
||||
{ userId: admin.id, plan: 'ENTERPRISE', status: 'ACTIVE' },
|
||||
{ userId: advogadoPro.id, plan: 'PRO', status: 'ACTIVE', stripeId: 'sub_mock_pro_001' },
|
||||
{ userId: advogadoFree.id, plan: 'FREE', status: 'ACTIVE' },
|
||||
],
|
||||
})
|
||||
|
||||
// ── Templates ──
|
||||
const templates = await Promise.all([
|
||||
prisma.template.create({
|
||||
data: {
|
||||
name: 'Petição Inicial Cível',
|
||||
description: 'Modelo de petição inicial para ações cíveis com fundamentação completa',
|
||||
type: 'PETICAO_INICIAL',
|
||||
area: 'CIVIL',
|
||||
prompt: 'Elabore uma petição inicial cível com os seguintes dados: {{fatos}}. Inclua fundamentação jurídica com base no CPC e CC, pedidos e valor da causa.',
|
||||
isPublic: true,
|
||||
userId: admin.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.create({
|
||||
data: {
|
||||
name: 'Contestação Trabalhista',
|
||||
description: 'Modelo de contestação para reclamações trabalhistas',
|
||||
type: 'CONTESTACAO',
|
||||
area: 'TRABALHISTA',
|
||||
prompt: 'Elabore uma contestação trabalhista para a reclamação: {{fatos}}. Inclua preliminares, mérito e impugnação aos pedidos com base na CLT.',
|
||||
isPublic: true,
|
||||
userId: admin.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.create({
|
||||
data: {
|
||||
name: 'Recurso de Apelação',
|
||||
description: 'Modelo de recurso de apelação com razões recursais',
|
||||
type: 'APELACAO',
|
||||
area: 'CIVIL',
|
||||
prompt: 'Elabore um recurso de apelação com base na sentença: {{sentenca}}. Apresente razões recursais, error in judicando/procedendo e pedido de reforma.',
|
||||
isPublic: true,
|
||||
userId: admin.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.create({
|
||||
data: {
|
||||
name: 'Contrato de Prestação de Serviços',
|
||||
description: 'Modelo de contrato de prestação de serviços advocatícios',
|
||||
type: 'CONTRATO',
|
||||
area: 'CIVIL',
|
||||
prompt: 'Elabore um contrato de prestação de serviços entre {{contratante}} e {{contratado}}. Escopo: {{escopo}}. Valor: {{valor}}. Inclua cláusulas de confidencialidade, rescisão e foro.',
|
||||
isPublic: true,
|
||||
userId: admin.id,
|
||||
},
|
||||
}),
|
||||
prisma.template.create({
|
||||
data: {
|
||||
name: 'Habeas Corpus',
|
||||
description: 'Modelo de habeas corpus preventivo ou liberatório',
|
||||
type: 'HABEAS_CORPUS',
|
||||
area: 'PENAL',
|
||||
prompt: 'Elabore um habeas corpus {{tipo}} em favor de {{paciente}}, contra ato de {{autoridade_coatora}}. Fatos: {{fatos}}. Fundamente no art. 5º, LXVIII da CF e arts. 647-667 do CPP.',
|
||||
isPublic: true,
|
||||
userId: admin.id,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
console.log('✅ Templates created:', templates.length)
|
||||
|
||||
// ── Jurisprudências ──
|
||||
const jurisprudencias = await Promise.all([
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STF',
|
||||
numero: 'RE 1.322.076/SP',
|
||||
ementa: 'RECURSO EXTRAORDINÁRIO. DIREITO DO CONSUMIDOR. RESPONSABILIDADE CIVIL. DANO MORAL. INSCRIÇÃO INDEVIDA EM CADASTRO DE INADIMPLENTES. A inscrição indevida do nome do consumidor em cadastros de proteção ao crédito configura dano moral in re ipsa, dispensando a comprovação do prejuízo efetivo. Recurso extraordinário não provido.',
|
||||
data: '2024-03-15',
|
||||
area: 'CONSUMIDOR',
|
||||
relator: 'Min. Luís Roberto Barroso',
|
||||
orgaoJulgador: 'Primeira Turma',
|
||||
tags: JSON.stringify(['dano moral', 'consumidor', 'cadastro inadimplentes', 'SPC', 'Serasa']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STJ',
|
||||
numero: 'REsp 2.045.123/RJ',
|
||||
ementa: 'RECURSO ESPECIAL. DIREITO CIVIL. RESPONSABILIDADE CIVIL. ACIDENTE DE TRÂNSITO. DANOS MATERIAIS E MORAIS. QUANTUM INDENIZATÓRIO. Revisão do valor da indenização por danos morais quando se mostrar irrisório ou excessivo. Manutenção do quantum fixado pelo Tribunal de origem dentro dos parâmetros de razoabilidade.',
|
||||
data: '2024-05-20',
|
||||
area: 'CIVIL',
|
||||
relator: 'Min. Nancy Andrighi',
|
||||
orgaoJulgador: 'Terceira Turma',
|
||||
tags: JSON.stringify(['acidente trânsito', 'dano moral', 'dano material', 'quantum indenizatório']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STF',
|
||||
numero: 'ADI 6.341/DF',
|
||||
ementa: 'AÇÃO DIRETA DE INCONSTITUCIONALIDADE. DIREITO ADMINISTRATIVO. COMPETÊNCIA CONCORRENTE. ESTADOS E MUNICÍPIOS. MEDIDAS SANITÁRIAS. Reconhecimento da competência concorrente de estados e municípios para adoção de medidas restritivas durante emergência sanitária. Interpretação conforme à Constituição.',
|
||||
data: '2024-01-10',
|
||||
area: 'ADMINISTRATIVO',
|
||||
relator: 'Min. Marco Aurélio',
|
||||
orgaoJulgador: 'Tribunal Pleno',
|
||||
tags: JSON.stringify(['competência concorrente', 'saúde pública', 'federalismo', 'autonomia']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STJ',
|
||||
numero: 'RHC 163.334/SC',
|
||||
ementa: 'RECURSO EM HABEAS CORPUS. DIREITO PENAL. FURTO. PRINCÍPIO DA INSIGNIFICÂNCIA. CRIME DE BAGATELA. Aplicação do princípio da insignificância ao furto de bem avaliado em valor inferior a 10% do salário mínimo. Atipicidade material da conduta. Recurso provido.',
|
||||
data: '2024-04-18',
|
||||
area: 'PENAL',
|
||||
relator: 'Min. Sebastião Reis Júnior',
|
||||
orgaoJulgador: 'Sexta Turma',
|
||||
tags: JSON.stringify(['furto', 'insignificância', 'bagatela', 'atipicidade']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STF',
|
||||
numero: 'ARE 1.121.633/GO',
|
||||
ementa: 'AGRAVO EM RECURSO EXTRAORDINÁRIO. DIREITO DO TRABALHO. TERCEIRIZAÇÃO. ATIVIDADE-FIM. LICITUDE. É lícita a terceirização ou qualquer outra forma de divisão do trabalho entre pessoas jurídicas distintas, independentemente do objeto social das empresas envolvidas, mantida a responsabilidade subsidiária da empresa contratante. Tema 725 de repercussão geral.',
|
||||
data: '2024-02-28',
|
||||
area: 'TRABALHISTA',
|
||||
relator: 'Min. Gilmar Mendes',
|
||||
orgaoJulgador: 'Tribunal Pleno',
|
||||
tags: JSON.stringify(['terceirização', 'atividade-fim', 'responsabilidade subsidiária', 'tema 725']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STJ',
|
||||
numero: 'REsp 1.869.043/SP',
|
||||
ementa: 'RECURSO ESPECIAL. DIREITO TRIBUTÁRIO. ICMS. BASE DE CÁLCULO. EXCLUSÃO DO PIS E COFINS. Em consonância com o entendimento firmado pelo STF no Tema 69, o ICMS a ser excluído da base de cálculo do PIS e da COFINS é o destacado na nota fiscal. Recurso especial não provido.',
|
||||
data: '2024-06-05',
|
||||
area: 'TRIBUTARIO',
|
||||
relator: 'Min. Herman Benjamin',
|
||||
orgaoJulgador: 'Segunda Turma',
|
||||
tags: JSON.stringify(['ICMS', 'PIS', 'COFINS', 'base de cálculo', 'tema 69']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STF',
|
||||
numero: 'RE 898.060/SC',
|
||||
ementa: 'RECURSO EXTRAORDINÁRIO. DIREITO DE FAMÍLIA. PATERNIDADE SOCIOAFETIVA. MULTIPARENTALIDADE. A paternidade socioafetiva, declarada ou não em registro público, não impede o reconhecimento do vínculo de filiação concomitante baseado na origem biológica, com os efeitos jurídicos próprios. Tema 622.',
|
||||
data: '2024-03-22',
|
||||
area: 'FAMILIA',
|
||||
relator: 'Min. Luiz Fux',
|
||||
orgaoJulgador: 'Tribunal Pleno',
|
||||
tags: JSON.stringify(['paternidade socioafetiva', 'multiparentalidade', 'filiação', 'tema 622']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STJ',
|
||||
numero: 'REsp 1.951.532/RS',
|
||||
ementa: 'RECURSO ESPECIAL. DIREITO EMPRESARIAL. RECUPERAÇÃO JUDICIAL. PLANO DE RECUPERAÇÃO. CRAM DOWN. Possibilidade de aprovação do plano de recuperação judicial pelo juiz mesmo sem a concordância de todas as classes de credores, desde que preenchidos os requisitos do art. 58, §1º da Lei 11.101/2005.',
|
||||
data: '2024-07-12',
|
||||
area: 'EMPRESARIAL',
|
||||
relator: 'Min. Ricardo Villas Bôas Cueva',
|
||||
orgaoJulgador: 'Terceira Turma',
|
||||
tags: JSON.stringify(['recuperação judicial', 'cram down', 'plano recuperação', 'credores']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STF',
|
||||
numero: 'HC 124.306/RJ',
|
||||
ementa: 'HABEAS CORPUS. DIREITO PENAL. ABORTO. INTERRUPÇÃO VOLUNTÁRIA DA GESTAÇÃO. PRIMEIRO TRIMESTRE. É preciso conferir interpretação conforme a Constituição aos arts. 124 a 126 do Código Penal para excluir do seu âmbito de incidência a interrupção voluntária da gestação efetivada no primeiro trimestre.',
|
||||
data: '2024-08-01',
|
||||
area: 'PENAL',
|
||||
relator: 'Min. Luís Roberto Barroso',
|
||||
orgaoJulgador: 'Primeira Turma',
|
||||
tags: JSON.stringify(['aborto', 'primeiro trimestre', 'interpretação conforme', 'direitos fundamentais']),
|
||||
},
|
||||
}),
|
||||
prisma.jurisprudencia.create({
|
||||
data: {
|
||||
tribunal: 'STJ',
|
||||
numero: 'REsp 1.733.013/PR',
|
||||
ementa: 'RECURSO ESPECIAL. DIREITO DO CONSUMIDOR. COMÉRCIO ELETRÔNICO. RESPONSABILIDADE DO MARKETPLACE. A plataforma digital que intermedia a venda de produtos responde solidariamente pelos vícios e defeitos dos produtos comercializados por terceiros em seu ambiente virtual, nos termos do CDC.',
|
||||
data: '2024-09-10',
|
||||
area: 'CONSUMIDOR',
|
||||
relator: 'Min. Paulo de Tarso Sanseverino',
|
||||
orgaoJulgador: 'Terceira Turma',
|
||||
tags: JSON.stringify(['marketplace', 'comércio eletrônico', 'responsabilidade solidária', 'CDC', 'plataforma digital']),
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
console.log('✅ Jurisprudências created:', jurisprudencias.length)
|
||||
|
||||
// ── Sample Documents ──
|
||||
const documents = await Promise.all([
|
||||
prisma.document.create({
|
||||
data: {
|
||||
userId: advogadoPro.id,
|
||||
type: 'PETICAO_INICIAL',
|
||||
title: 'Petição Inicial - Ação de Indenização por Danos Morais',
|
||||
prompt: 'Elaborar petição inicial para ação de indenização por danos morais decorrente de inscrição indevida no SPC',
|
||||
content: 'EXCELENTÍSSIMO SENHOR DOUTOR JUIZ DE DIREITO DA ___ VARA CÍVEL DA COMARCA DE SÃO PAULO/SP\n\nMARIA DA SILVA, brasileira, solteira, professora, portadora do RG nº 12.345.678-9 e CPF nº 123.456.789-00, residente e domiciliada na Rua das Flores, nº 100, Jardim Primavera, São Paulo/SP, CEP 01234-567, vem, respeitosamente, à presença de Vossa Excelência, por intermédio de seus procuradores que esta subscrevem, propor a presente AÇÃO DE INDENIZAÇÃO POR DANOS MORAIS em face de BANCO XYZ S/A...\n\n[Documento completo gerado pela IA]',
|
||||
wordCount: 2500,
|
||||
status: 'COMPLETED',
|
||||
area: 'CONSUMIDOR',
|
||||
tokens: 3200,
|
||||
cost: 0.032,
|
||||
},
|
||||
}),
|
||||
prisma.document.create({
|
||||
data: {
|
||||
userId: advogadoPro.id,
|
||||
type: 'CONTESTACAO',
|
||||
title: 'Contestação - Reclamação Trabalhista',
|
||||
prompt: 'Elaborar contestação para reclamação trabalhista sobre horas extras e adicional noturno',
|
||||
content: 'EXCELENTÍSSIMO SENHOR DOUTOR JUIZ DO TRABALHO DA ___ VARA DO TRABALHO DE SÃO PAULO/SP\n\nProcesso nº 0001234-56.2024.5.02.0001\n\nEMPRESA ABC LTDA, pessoa jurídica de direito privado, inscrita no CNPJ sob nº 12.345.678/0001-90, com sede na Avenida Paulista, nº 1000, São Paulo/SP, vem, respeitosamente, apresentar CONTESTAÇÃO à reclamação trabalhista movida por JOÃO CARLOS SOUZA...\n\n[Documento completo gerado pela IA]',
|
||||
wordCount: 3100,
|
||||
status: 'COMPLETED',
|
||||
area: 'TRABALHISTA',
|
||||
tokens: 4100,
|
||||
cost: 0.041,
|
||||
},
|
||||
}),
|
||||
prisma.document.create({
|
||||
data: {
|
||||
userId: advogadoFree.id,
|
||||
type: 'CONTRATO',
|
||||
title: 'Contrato de Honorários Advocatícios',
|
||||
prompt: 'Elaborar contrato de honorários para consultoria jurídica empresarial mensal',
|
||||
content: 'CONTRATO DE PRESTAÇÃO DE SERVIÇOS ADVOCATÍCIOS\n\nPelo presente instrumento particular, de um lado EMPRESA DELTA COMÉRCIO LTDA como CONTRATANTE e de outro a DRA. JOÃO SANTOS - OAB/RJ 654321 como CONTRATADO...\n\n[Documento completo gerado pela IA]',
|
||||
wordCount: 1800,
|
||||
status: 'COMPLETED',
|
||||
area: 'EMPRESARIAL',
|
||||
tokens: 2200,
|
||||
cost: 0.022,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
console.log('✅ Documents created:', documents.length)
|
||||
|
||||
// ── Sample Chat ──
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
userId: advogadoPro.id,
|
||||
title: 'Dúvida sobre prescrição em ação de cobrança',
|
||||
messages: {
|
||||
create: [
|
||||
{
|
||||
role: 'USER',
|
||||
content: 'Qual o prazo prescricional para ação de cobrança de dívida por contrato verbal?',
|
||||
tokens: 25,
|
||||
},
|
||||
{
|
||||
role: 'ASSISTANT',
|
||||
content: 'O prazo prescricional para ação de cobrança de dívida originada de contrato verbal é de **10 anos**, conforme o art. 205 do Código Civil de 2002, que estabelece o prazo prescricional genérico. Contudo, se houver documento escrito (como um título de crédito), o prazo pode ser diferente:\n\n- **Cheque:** 6 meses (art. 59, Lei 7.357/85)\n- **Nota promissória:** 3 anos (art. 70, LUG)\n- **Duplicata:** 3 anos (art. 18, Lei 5.474/68)\n\nImportante: O prazo começa a contar da data do vencimento da dívida (art. 199, CC).',
|
||||
tokens: 180,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('✅ Chat created:', chat.title)
|
||||
|
||||
// ── Usage Logs ──
|
||||
await prisma.usageLog.createMany({
|
||||
data: [
|
||||
{ userId: advogadoPro.id, type: 'DOCUMENT', tokens: 3200, cost: 0.032 },
|
||||
{ userId: advogadoPro.id, type: 'DOCUMENT', tokens: 4100, cost: 0.041 },
|
||||
{ userId: advogadoPro.id, type: 'CHAT', tokens: 205, cost: 0.002 },
|
||||
{ userId: advogadoFree.id, type: 'DOCUMENT', tokens: 2200, cost: 0.022 },
|
||||
{ userId: advogadoPro.id, type: 'JURISPRUDENCIA', tokens: 150, cost: 0.001 },
|
||||
],
|
||||
})
|
||||
|
||||
console.log('✅ Usage logs created')
|
||||
|
||||
// ── API Keys ──
|
||||
await prisma.apiKey.create({
|
||||
data: {
|
||||
key: 'jur_live_sk_' + 'a'.repeat(32),
|
||||
name: 'API Principal',
|
||||
userId: advogadoPro.id,
|
||||
active: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('✅ API keys created')
|
||||
console.log('\n🎉 Seed completed successfully!')
|
||||
console.log('\n📋 Login credentials (all passwords: 123456):')
|
||||
console.log(' Admin: admin@juridico.ai')
|
||||
console.log(' Pro: maria@advocacia.com')
|
||||
console.log(' Free: joao@email.com')
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#0a0f1a"/>
|
||||
<path d="M16 3L28 9.5v13L16 29 4 22.5v-13L16 3z" fill="#0d9488" opacity="0.2"/>
|
||||
<text x="16" y="22" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="700" fill="#2dd4bf">§</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
18
public/logo.svg
Normal file
18
public/logo.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<!-- Outer hexagonal frame -->
|
||||
<path d="M24 2L42.5 13v22L24 46 5.5 35V13L24 2z" stroke="#14b8a6" stroke-width="1.5" fill="none" opacity="0.3"/>
|
||||
<!-- Inner shield shape -->
|
||||
<path d="M24 7L38 15.5v17L24 41 10 32.5v-17L24 7z" fill="#0d9488" opacity="0.15"/>
|
||||
<!-- § symbol stylized -->
|
||||
<text x="24" y="31" text-anchor="middle" font-family="Georgia, serif" font-size="22" font-weight="700" fill="#2dd4bf">§</text>
|
||||
<!-- Neural connection dots -->
|
||||
<circle cx="12" cy="18" r="1.5" fill="#14b8a6" opacity="0.6"/>
|
||||
<circle cx="36" cy="18" r="1.5" fill="#14b8a6" opacity="0.6"/>
|
||||
<circle cx="12" cy="30" r="1.5" fill="#14b8a6" opacity="0.6"/>
|
||||
<circle cx="36" cy="30" r="1.5" fill="#14b8a6" opacity="0.6"/>
|
||||
<!-- Neural lines -->
|
||||
<line x1="12" y1="18" x2="20" y2="22" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
|
||||
<line x1="36" y1="18" x2="28" y2="22" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
|
||||
<line x1="12" y1="30" x2="20" y2="26" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
|
||||
<line x1="36" y1="30" x2="28" y2="26" stroke="#14b8a6" stroke-width="0.7" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
169
scripts/buscar-publicacoes.ts
Normal file
169
scripts/buscar-publicacoes.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Script de Busca Diária de Publicações
|
||||
*
|
||||
* Executa busca de publicações em todos os processos ativos de todos os usuários.
|
||||
* Pode ser executado via cron ou manualmente.
|
||||
*
|
||||
* Uso: npx ts-node scripts/buscar-publicacoes.ts
|
||||
* Ou: node dist/scripts/buscar-publicacoes.js (se compilado)
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { buscarPublicacoesReais, buscarPublicacoesEmLote, PublicacaoEncontrada } from '../src/lib/diarios-service'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
interface BuscaStats {
|
||||
totalProcessos: number
|
||||
processosComNovas: number
|
||||
totalPublicacoes: number
|
||||
erros: string[]
|
||||
inicioEm: Date
|
||||
fimEm?: Date
|
||||
}
|
||||
|
||||
async function buscarPublicacoesDiarias(): Promise<BuscaStats> {
|
||||
const stats: BuscaStats = {
|
||||
totalProcessos: 0,
|
||||
processosComNovas: 0,
|
||||
totalPublicacoes: 0,
|
||||
erros: [],
|
||||
inicioEm: new Date(),
|
||||
}
|
||||
|
||||
console.log('='.repeat(60))
|
||||
console.log(`[${new Date().toISOString()}] Iniciando busca diária de publicações`)
|
||||
console.log('='.repeat(60))
|
||||
|
||||
try {
|
||||
// Busca todos os processos ativos
|
||||
const processos = await prisma.processoMonitorado.findMany({
|
||||
where: { status: 'ATIVO' },
|
||||
select: {
|
||||
id: true,
|
||||
numeroProcesso: true,
|
||||
tribunal: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: { email: true }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
stats.totalProcessos = processos.length
|
||||
console.log(`\n📋 Total de processos ativos: ${processos.length}`)
|
||||
|
||||
if (processos.length === 0) {
|
||||
console.log('Nenhum processo ativo encontrado.')
|
||||
return stats
|
||||
}
|
||||
|
||||
// Busca publicações em lote com rate limiting (500ms entre requisições)
|
||||
console.log('\n🔍 Buscando publicações nos diários oficiais...\n')
|
||||
|
||||
const resultados = await buscarPublicacoesEmLote(
|
||||
processos.map(p => ({
|
||||
id: p.id,
|
||||
numeroProcesso: p.numeroProcesso,
|
||||
tribunal: p.tribunal,
|
||||
})),
|
||||
500 // delay entre requisições
|
||||
)
|
||||
|
||||
// Processa resultados
|
||||
for (const processo of processos) {
|
||||
const resultado = resultados.get(processo.id)
|
||||
|
||||
if (!resultado) {
|
||||
stats.erros.push(`${processo.numeroProcesso}: Sem resultado`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
stats.erros.push(`${processo.numeroProcesso}: ${resultado.erro}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let novasDoProcesso = 0
|
||||
|
||||
for (const pub of resultado.publicacoes) {
|
||||
// Verifica duplicata
|
||||
const existing = await prisma.publicacao.findFirst({
|
||||
where: {
|
||||
processoId: processo.id,
|
||||
dataPublicacao: {
|
||||
gte: new Date(pub.dataPublicacao.toDateString()),
|
||||
lt: new Date(new Date(pub.dataPublicacao).setDate(pub.dataPublicacao.getDate() + 1)),
|
||||
},
|
||||
tipo: pub.tipo,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.publicacao.create({
|
||||
data: {
|
||||
processoId: processo.id,
|
||||
dataPublicacao: pub.dataPublicacao,
|
||||
diario: pub.diario,
|
||||
conteudo: pub.conteudo,
|
||||
tipo: pub.tipo,
|
||||
prazoCalculado: pub.prazoCalculado,
|
||||
prazoTipo: pub.prazoTipo,
|
||||
visualizado: false,
|
||||
},
|
||||
})
|
||||
novasDoProcesso++
|
||||
stats.totalPublicacoes++
|
||||
}
|
||||
}
|
||||
|
||||
if (novasDoProcesso > 0) {
|
||||
stats.processosComNovas++
|
||||
console.log(` ✅ ${processo.numeroProcesso}: ${novasDoProcesso} nova(s) publicação(ões)`)
|
||||
} else {
|
||||
console.log(` ➖ ${processo.numeroProcesso}: sem novas publicações`)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Erro desconhecido'
|
||||
stats.erros.push(`Erro geral: ${msg}`)
|
||||
console.error('\n❌ Erro durante execução:', error)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
stats.fimEm = new Date()
|
||||
|
||||
// Resumo final
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('📊 RESUMO DA BUSCA')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Processos verificados: ${stats.totalProcessos}`)
|
||||
console.log(`Processos com novas publicações: ${stats.processosComNovas}`)
|
||||
console.log(`Total de novas publicações: ${stats.totalPublicacoes}`)
|
||||
console.log(`Erros: ${stats.erros.length}`)
|
||||
|
||||
if (stats.erros.length > 0) {
|
||||
console.log('\n⚠️ Erros encontrados:')
|
||||
stats.erros.forEach(e => console.log(` - ${e}`))
|
||||
}
|
||||
|
||||
const duracao = ((stats.fimEm.getTime() - stats.inicioEm.getTime()) / 1000).toFixed(1)
|
||||
console.log(`\n⏱️ Tempo de execução: ${duracao}s`)
|
||||
console.log(`[${stats.fimEm.toISOString()}] Busca finalizada\n`)
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// Executar
|
||||
buscarPublicacoesDiarias()
|
||||
.then(stats => {
|
||||
process.exit(stats.erros.length > 0 ? 1 : 0)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Erro fatal:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
36
scripts/setup-stripe.ts
Normal file
36
scripts/setup-stripe.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: '2025-04-30.basil' as any,
|
||||
})
|
||||
|
||||
const plans = [
|
||||
{ name: 'Teste', amount: 1, interval: 'month' as const },
|
||||
{ name: 'Starter', amount: 9700, interval: 'month' as const },
|
||||
{ name: 'Pro', amount: 19700, interval: 'month' as const },
|
||||
{ name: 'Enterprise', amount: 49700, interval: 'month' as const },
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('Creating Stripe products and prices...\n')
|
||||
|
||||
for (const plan of plans) {
|
||||
const product = await stripe.products.create({
|
||||
name: `LexMind ${plan.name}`,
|
||||
description: `Plano ${plan.name} - LexMind`,
|
||||
})
|
||||
|
||||
const price = await stripe.prices.create({
|
||||
product: product.id,
|
||||
unit_amount: plan.amount,
|
||||
currency: 'brl',
|
||||
recurring: { interval: plan.interval },
|
||||
})
|
||||
|
||||
console.log(`${plan.name}: product=${product.id} price=${price.id} (R$${(plan.amount / 100).toFixed(2)}/month)`)
|
||||
}
|
||||
|
||||
console.log('\nDone! Copy the price IDs above into your code.')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
102
scripts/testar-datajud.ts
Normal file
102
scripts/testar-datajud.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Script de Teste da API DataJud
|
||||
* Testa a conexão e busca um processo de exemplo
|
||||
*/
|
||||
|
||||
// API Key pública do DataJud (CNJ)
|
||||
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
|
||||
const DATAJUD_BASE_URL = 'https://api-publica.datajud.cnj.jus.br'
|
||||
|
||||
async function testarDataJud() {
|
||||
console.log('='.repeat(60))
|
||||
console.log('🧪 Testando conexão com API DataJud (CNJ)')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
// Teste 1: Buscar um processo aleatório
|
||||
console.log('\n1. Buscando processo de exemplo no TJSP...')
|
||||
|
||||
const url = `${DATAJUD_BASE_URL}/api_publica_tjsp/_search`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: 1,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{ exists: { field: 'movimentos' } }
|
||||
],
|
||||
filter: [
|
||||
{ range: { '@timestamp': { gte: 'now-7d' } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log(`❌ Erro HTTP: ${response.status}`)
|
||||
const text = await response.text()
|
||||
console.log(text.substring(0, 500))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log(`✅ Resposta OK - ${data.hits?.total?.value || 0} processos no índice`)
|
||||
|
||||
if (data.hits?.hits?.length > 0) {
|
||||
const processo = data.hits.hits[0]._source
|
||||
console.log(`\n📄 Processo encontrado:`)
|
||||
console.log(` Número: ${processo.numeroProcesso}`)
|
||||
console.log(` Classe: ${processo.classe?.nome || 'N/A'}`)
|
||||
console.log(` Tribunal: ${processo.tribunal}`)
|
||||
console.log(` Movimentos: ${processo.movimentos?.length || 0}`)
|
||||
|
||||
// Mostrar últimos movimentos
|
||||
const ultimos = (processo.movimentos || []).slice(-5)
|
||||
console.log('\n Últimos movimentos:')
|
||||
ultimos.forEach((m: any) => {
|
||||
console.log(` - ${m.dataHora?.substring(0, 10)} | ${m.nome} (código: ${m.codigo})`)
|
||||
})
|
||||
}
|
||||
|
||||
// Teste 2: Buscar processo específico (se existir)
|
||||
console.log('\n\n2. Testando busca por número específico...')
|
||||
|
||||
const response2 = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: 1,
|
||||
query: {
|
||||
match: {
|
||||
numeroProcesso: '10000000020248260100' // Exemplo fictício
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const data2 = await response2.json()
|
||||
const found = data2.hits?.hits?.length > 0
|
||||
console.log(` Processo de teste: ${found ? '✅ Encontrado' : '➖ Não encontrado (esperado)'}`)
|
||||
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('✅ API DataJud funcionando corretamente!')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao conectar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
testarDataJud()
|
||||
64
scripts/teste-integracao.ts
Normal file
64
scripts/teste-integracao.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Teste de Integração Completa
|
||||
* Testa buscarPublicacoesReais com um número de processo real
|
||||
*/
|
||||
|
||||
import { buscarPublicacoesReais, buscarDataJud } from '../src/lib/diarios-service'
|
||||
|
||||
async function testarIntegracao() {
|
||||
console.log('='.repeat(60))
|
||||
console.log('🧪 Teste de Integração - Busca de Publicações')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
// Processo de teste (usando um real do TJSP que sabemos existir)
|
||||
const processoTeste = {
|
||||
id: 'teste-001',
|
||||
numeroProcesso: '1000044-50.2025.8.26.0220',
|
||||
tribunal: 'TJSP',
|
||||
}
|
||||
|
||||
console.log(`\n📋 Processo de teste:`)
|
||||
console.log(` Número: ${processoTeste.numeroProcesso}`)
|
||||
console.log(` Tribunal: ${processoTeste.tribunal}`)
|
||||
|
||||
console.log('\n🔍 Buscando publicações...\n')
|
||||
|
||||
try {
|
||||
const resultado = await buscarPublicacoesReais(processoTeste, 365) // Últimos 365 dias
|
||||
|
||||
console.log(`✅ Busca concluída!`)
|
||||
console.log(` Sucesso: ${resultado.sucesso}`)
|
||||
console.log(` Fonte: ${resultado.fonte}`)
|
||||
console.log(` Publicações encontradas: ${resultado.publicacoes.length}`)
|
||||
|
||||
if (resultado.erro) {
|
||||
console.log(` Erro: ${resultado.erro}`)
|
||||
}
|
||||
|
||||
if (resultado.publicacoes.length > 0) {
|
||||
console.log('\n📰 Publicações encontradas:')
|
||||
resultado.publicacoes.slice(0, 10).forEach((pub, i) => {
|
||||
console.log(`\n ${i + 1}. ${pub.tipo}`)
|
||||
console.log(` Data: ${pub.dataPublicacao.toISOString().split('T')[0]}`)
|
||||
console.log(` Prazo: ${pub.prazoCalculado?.toISOString().split('T')[0]} (${pub.prazoTipo})`)
|
||||
console.log(` Conteúdo: ${pub.conteudo.substring(0, 100)}...`)
|
||||
})
|
||||
}
|
||||
|
||||
// Testar também busca direta no DataJud
|
||||
console.log('\n\n🔬 Testando busca direta DataJud (processo diferente)...')
|
||||
const resultado2 = await buscarDataJud('0020077-82.2022.8.26.0576', 'TJSP')
|
||||
console.log(` Publicações: ${resultado2.publicacoes.length}`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro:', error)
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60))
|
||||
console.log('✅ Teste de integração finalizado')
|
||||
console.log('='.repeat(60))
|
||||
}
|
||||
|
||||
testarIntegracao()
|
||||
84
scripts/teste-standalone.ts
Normal file
84
scripts/teste-standalone.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Teste Standalone da API DataJud
|
||||
* Não depende de imports externos
|
||||
*/
|
||||
|
||||
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
|
||||
|
||||
interface Movimento {
|
||||
codigo: number
|
||||
nome: string
|
||||
dataHora: string
|
||||
orgaoJulgador?: { nome: string }
|
||||
complementosTabelados?: Array<{ nome: string }>
|
||||
}
|
||||
|
||||
async function buscarDataJud(numeroProcesso: string, tribunal: string) {
|
||||
const endpoint = `api_publica_${tribunal.toLowerCase()}`
|
||||
const url = `https://api-publica.datajud.cnj.jus.br/${endpoint}/_search`
|
||||
const numeroLimpo = numeroProcesso.replace(/\D/g, '')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: 1,
|
||||
query: { match: { numeroProcesso: numeroLimpo } },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60))
|
||||
console.log('🧪 Teste Standalone - API DataJud')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
// Teste com processo que encontramos antes
|
||||
const processoNumero = '1000044-50.2025.8.26.0220'
|
||||
console.log(`\n📋 Buscando: ${processoNumero}`)
|
||||
|
||||
try {
|
||||
const data = await buscarDataJud(processoNumero, 'TJSP')
|
||||
const hits = data.hits?.hits || []
|
||||
|
||||
if (hits.length === 0) {
|
||||
console.log('❌ Processo não encontrado')
|
||||
return
|
||||
}
|
||||
|
||||
const processo = hits[0]._source
|
||||
console.log(`\n✅ Processo encontrado!`)
|
||||
console.log(` Classe: ${processo.classe?.nome}`)
|
||||
console.log(` Órgão: ${processo.orgaoJulgador?.nome}`)
|
||||
console.log(` Total movimentos: ${processo.movimentos?.length || 0}`)
|
||||
|
||||
// Filtrar publicações
|
||||
const publicacoes = (processo.movimentos || []).filter((m: Movimento) =>
|
||||
m.codigo === 92 || // Publicação
|
||||
m.nome?.toLowerCase().includes('publicação') ||
|
||||
m.nome?.toLowerCase().includes('intimação') ||
|
||||
m.nome?.toLowerCase().includes('citação')
|
||||
)
|
||||
|
||||
console.log(`\n📰 Publicações/Intimações (${publicacoes.length}):`)
|
||||
publicacoes.slice(0, 10).forEach((m: Movimento, i: number) => {
|
||||
console.log(` ${i + 1}. ${m.dataHora?.substring(0, 10)} | ${m.nome}`)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro:', error)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
1191
seed-jurisprudencia.js
Normal file
1191
seed-jurisprudencia.js
Normal file
File diff suppressed because it is too large
Load Diff
85
src/app/FAQSection.tsx
Normal file
85
src/app/FAQSection.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'O LexMind dispensa a atuação do advogado?',
|
||||
a: 'De forma alguma. O LexMind é um instrumento de produtividade e assistência técnica. Toda peça produzida pela plataforma deve passar pela revisão e validação do profissional responsável. A inteligência artificial potencializa o trabalho humano — não o substitui.',
|
||||
},
|
||||
{
|
||||
q: 'Quais categorias de documentos posso gerar?',
|
||||
a: 'A plataforma cobre um amplo espectro: petições iniciais, contestações, apelações, agravos, embargos, pareceres jurídicos, contratos, notificações extrajudiciais, habeas corpus, mandados de segurança e dezenas de outros modelos adaptados às principais áreas do ordenamento brasileiro.',
|
||||
},
|
||||
{
|
||||
q: 'Qual o nível de confiabilidade das peças geradas?',
|
||||
a: 'Nossa IA opera com base em legislação atualizada e jurisprudência consolidada dos tribunais superiores e estaduais. A taxa de aproveitamento integral supera 90%, mas ressaltamos: a responsabilidade técnica permanece com o advogado subscritor, conforme o Código de Ética da OAB.',
|
||||
},
|
||||
{
|
||||
q: 'Como é tratada a confidencialidade dos dados?',
|
||||
a: 'Com rigor absoluto. Empregamos criptografia AES-256 para dados em repouso e TLS 1.3 em trânsito. Seus dados e os de seus clientes jamais são utilizados para treinamento de modelos. Operamos em total conformidade com a LGPD e as diretrizes de sigilo profissional.',
|
||||
},
|
||||
{
|
||||
q: 'É possível cancelar a assinatura sem penalidade?',
|
||||
a: 'Sim, a qualquer momento, sem multa ou burocracia. O cancelamento pode ser feito diretamente pelo painel de controle. Seu acesso permanece ativo até o término do ciclo vigente.',
|
||||
},
|
||||
{
|
||||
q: 'Há condições especiais para escritórios ou membros da OAB?',
|
||||
a: 'Oferecemos descontos para portadores de carteira da OAB e planos diferenciados para escritórios com mais de 5 advogados. Entre em contato pela plataforma ou por e-mail para uma proposta personalizada.',
|
||||
},
|
||||
]
|
||||
|
||||
export function FAQ() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => {
|
||||
const isOpen = openIndex === i
|
||||
return (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ delay: i * 0.05, duration: 0.4 }}
|
||||
className={`rounded-2xl border transition-colors duration-300 overflow-hidden ${
|
||||
isOpen
|
||||
? 'border-teal-500/20 bg-teal-500/[0.03]'
|
||||
: 'border-white/[0.06] bg-white/[0.015] hover:border-white/[0.1]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||
className="w-full flex items-center justify-between px-6 py-5 text-left group"
|
||||
>
|
||||
<span className="text-[15px] font-medium text-white pr-4 leading-snug">{faq.q}</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 group-hover:text-teal-400 transition-colors" />
|
||||
</motion.div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pb-5 text-sm text-gray-400 leading-relaxed">{faq.a}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
src/app/admin/layout.tsx
Normal file
111
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
BookOpen,
|
||||
FileStack,
|
||||
Settings,
|
||||
Shield,
|
||||
ArrowLeft,
|
||||
} from "lucide-react"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const sidebarItems = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/admin/users", label: "Usuários", icon: Users },
|
||||
{ href: "/admin/documents", label: "Documentos", icon: FileText },
|
||||
{ href: "/admin/templates", label: "Templates", icon: FileStack },
|
||||
{ href: "/admin/jurisprudencia", label: "Jurisprudência", icon: BookOpen },
|
||||
{ href: "/admin/settings", label: "Configurações", icon: Settings },
|
||||
]
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return
|
||||
if (!session?.user) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
if (session.user.role !== "ADMIN") {
|
||||
router.push("/dashboard")
|
||||
}
|
||||
}, [session, status, router])
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-teal-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<div className="p-6 border-b border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-teal-600 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white font-bold text-lg">Admin</h1>
|
||||
<p className="text-gray-400 text-xs">LexMind</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{sidebarItems.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== "/admin" && pathname.startsWith(item.href))
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all ${
|
||||
isActive
|
||||
? "bg-teal-600/20 text-teal-400 border border-teal-500/30"
|
||||
: "text-gray-400 hover:text-white hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Voltar ao Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/app/api/admin/stats/route.ts
Normal file
80
src/app/api/admin/stats/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user || session.user.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 403 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
totalDocuments,
|
||||
activeSubscriptions,
|
||||
recentUsers,
|
||||
recentDocuments,
|
||||
planDistribution,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.document.count(),
|
||||
prisma.subscription.count({ where: { status: "ACTIVE" } }),
|
||||
prisma.user.findMany({
|
||||
where: { createdAt: { gte: thirtyDaysAgo } },
|
||||
select: { createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.document.findMany({
|
||||
where: { createdAt: { gte: thirtyDaysAgo } },
|
||||
select: { createdAt: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.user.groupBy({
|
||||
by: ["plan"],
|
||||
_count: { plan: true },
|
||||
}),
|
||||
])
|
||||
|
||||
// Aggregate signups by day
|
||||
const signupsByDay: Record<string, number> = {}
|
||||
const docsByDay: Record<string, number> = {}
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000)
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
signupsByDay[key] = 0
|
||||
docsByDay[key] = 0
|
||||
}
|
||||
|
||||
for (const u of recentUsers) {
|
||||
const key = u.createdAt.toISOString().slice(0, 10)
|
||||
if (signupsByDay[key] !== undefined) signupsByDay[key]++
|
||||
}
|
||||
|
||||
for (const d of recentDocuments) {
|
||||
const key = d.createdAt.toISOString().slice(0, 10)
|
||||
if (docsByDay[key] !== undefined) docsByDay[key]++
|
||||
}
|
||||
|
||||
// Mock revenue based on subscriptions
|
||||
const plans: Record<string, number> = {}
|
||||
for (const p of planDistribution) {
|
||||
plans[p.plan] = p._count.plan
|
||||
}
|
||||
const mockRevenue =
|
||||
(plans["PRO"] || 0) * 97 + (plans["ENTERPRISE"] || 0) * 297
|
||||
|
||||
return NextResponse.json({
|
||||
totalUsers,
|
||||
totalDocuments,
|
||||
activeSubscriptions,
|
||||
monthlyRevenue: mockRevenue,
|
||||
signupsByDay: Object.entries(signupsByDay).map(([date, count]) => ({ date, count })),
|
||||
docsByDay: Object.entries(docsByDay).map(([date, count]) => ({ date, count })),
|
||||
planDistribution: plans,
|
||||
})
|
||||
}
|
||||
96
src/app/api/analise-processo/[id]/peticao/route.ts
Normal file
96
src/app/api/analise-processo/[id]/peticao/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const analysis = await prisma.processAnalysis.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: session.user.id,
|
||||
status: 'DONE',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
analysis: true,
|
||||
extractedText: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!analysis) {
|
||||
return NextResponse.json({ error: 'Análise não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Você é um assistente jurídico especializado. Extraia informações estruturadas do parecer jurídico fornecido.
|
||||
Responda APENAS com JSON válido, sem markdown, sem code blocks. O formato deve ser:
|
||||
{
|
||||
"autor": "nome do autor/requerente ou string vazia",
|
||||
"reu": "nome do réu/requerido ou string vazia",
|
||||
"fatos": "resumo dos fatos principais",
|
||||
"fundamentos": "fundamentos jurídicos identificados (artigos, leis, jurisprudência)",
|
||||
"pedidos": "pedidos sugeridos com base na análise",
|
||||
"area": "uma das opções: civil, trabalhista, penal, tributario, familia, empresarial, consumidor, administrativo"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Extraia as informações do seguinte parecer jurídico:\n\n${analysis.analysis.substring(0, 8000)}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const content = completion.choices[0]?.message?.content || '{}'
|
||||
|
||||
let extracted
|
||||
try {
|
||||
extracted = JSON.parse(content)
|
||||
} catch {
|
||||
// Try to extract JSON from the response if it has markdown wrapping
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/)
|
||||
extracted = jsonMatch ? JSON.parse(jsonMatch[0]) : {}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
analysisId: analysis.id,
|
||||
filename: analysis.filename,
|
||||
title: analysis.title,
|
||||
parecer: analysis.analysis,
|
||||
extracted: {
|
||||
autor: extracted.autor || '',
|
||||
reu: extracted.reu || '',
|
||||
fatos: extracted.fatos || '',
|
||||
fundamentos: extracted.fundamentos || '',
|
||||
pedidos: extracted.pedidos || '',
|
||||
area: extracted.area || 'civil',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error extracting petition data:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao extrair dados do parecer' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/app/api/analise-processo/[id]/route.ts
Normal file
40
src/app/api/analise-processo/[id]/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const analysis = await prisma.processAnalysis.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
fileSize: true,
|
||||
extractedText: true,
|
||||
analysis: true,
|
||||
summary: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!analysis) {
|
||||
return NextResponse.json({ error: 'Análise não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ analysis })
|
||||
}
|
||||
254
src/app/api/analise-processo/route.ts
Normal file
254
src/app/api/analise-processo/route.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
export const config = { api: { bodyParser: false } };
|
||||
export const maxDuration = 120;
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadFile, buildKey } from '@/lib/spaces'
|
||||
import OpenAI from 'openai'
|
||||
import pdfParse from "pdf-parse/lib/pdf-parse.js"
|
||||
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const SYSTEM_PROMPT = `Você é um jurista especialista em Direito brasileiro com amplo conhecimento da legislação vigente.
|
||||
Analise o processo/documento jurídico a seguir e forneça um parecer completo e detalhado.
|
||||
|
||||
Sua análise DEVE incluir:
|
||||
|
||||
## 1. Resumo do Processo
|
||||
Breve resumo dos fatos, partes envolvidas e objeto da ação.
|
||||
|
||||
## 2. Tipo de Ação e Rito Processual
|
||||
Identifique o tipo de ação, o rito processual aplicável e a competência.
|
||||
|
||||
## 3. Análise dos Fundamentos Jurídicos
|
||||
Analise os fundamentos de direito apresentados, verificando:
|
||||
- Adequação legal
|
||||
- Citações de artigos de lei
|
||||
- Jurisprudência aplicável
|
||||
|
||||
## 4. Pontos Fortes
|
||||
Identifique os argumentos mais sólidos e bem fundamentados.
|
||||
|
||||
## 5. Pontos Fracos e Vulnerabilidades
|
||||
Identifique falhas, lacunas ou argumentos frágeis que podem ser explorados pela parte contrária.
|
||||
|
||||
## 6. Legislação Aplicável Atualizada
|
||||
Liste TODA a legislação relevante com artigos específicos:
|
||||
- Constituição Federal de 1988
|
||||
- Códigos (CPC, CC, CLT, CDC, CP, CTN, etc.)
|
||||
- Leis especiais aplicáveis
|
||||
- Súmulas vinculantes e súmulas do STJ/STF relevantes
|
||||
|
||||
## 7. Jurisprudência Relevante
|
||||
Cite decisões recentes dos tribunais superiores (STF, STJ, TST) e tribunais estaduais que se aplicam ao caso.
|
||||
|
||||
## 8. Parecer e Recomendações
|
||||
Apresente seu parecer sobre:
|
||||
- Chances de êxito (alta/média/baixa)
|
||||
- Estratégia processual recomendada
|
||||
- Providências urgentes, se houver
|
||||
- Recursos cabíveis
|
||||
- Prazos importantes a observar
|
||||
|
||||
## 9. Conclusão
|
||||
Síntese final com recomendação objetiva.
|
||||
|
||||
Use linguagem técnica jurídica. Cite artigos de lei sempre que possível. Base sua análise na legislação brasileira vigente em 2025/2026.`
|
||||
|
||||
const CREDIT_COST = 5
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const analyses = await prisma.processAnalysis.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
fileSize: true,
|
||||
status: true,
|
||||
summary: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ analyses })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check credits
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } })
|
||||
if (!user || user.credits < CREDIT_COST) {
|
||||
return NextResponse.json(
|
||||
{ error: `Créditos insuficientes. A análise de processo custa ${CREDIT_COST} créditos.` },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Nenhum arquivo enviado' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
return NextResponse.json({ error: 'Apenas arquivos PDF são aceitos' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'Arquivo muito grande. Máximo: 50MB' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
|
||||
// Extract text from PDF
|
||||
let extractedText: string
|
||||
try {
|
||||
const pdfData = await pdfParse(buffer)
|
||||
extractedText = pdfData.text
|
||||
} catch (pdfErr) {
|
||||
console.error('PDF parse error:', pdfErr)
|
||||
return NextResponse.json({ error: 'Erro ao ler o PDF. Verifique se o arquivo não está corrompido.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!extractedText || extractedText.trim().length < 50) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Não foi possível extrair texto suficiente do PDF. O arquivo pode ser uma imagem digitalizada.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Upload PDF to Spaces
|
||||
const key = buildKey(session.user.id, file.name)
|
||||
await uploadFile(buffer, key, 'application/pdf')
|
||||
|
||||
// Truncate if needed
|
||||
let textForAnalysis = extractedText
|
||||
let truncated = false
|
||||
if (textForAnalysis.length > 15000) {
|
||||
textForAnalysis = textForAnalysis.substring(0, 15000)
|
||||
truncated = true
|
||||
}
|
||||
|
||||
const title = file.name.replace(/\.pdf$/i, '')
|
||||
|
||||
// Create record
|
||||
const analysis = await prisma.processAnalysis.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title,
|
||||
filename: file.name,
|
||||
fileKey: key,
|
||||
fileSize: file.size,
|
||||
extractedText,
|
||||
analysis: '',
|
||||
status: 'ANALYZING',
|
||||
},
|
||||
})
|
||||
|
||||
// Deduct credits
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: CREDIT_COST } },
|
||||
})
|
||||
|
||||
// Build user message
|
||||
let userMessage = `Analise o seguinte processo/documento jurídico:\n\n${textForAnalysis}`
|
||||
if (truncated) {
|
||||
userMessage += '\n\n[NOTA: O documento foi truncado para os primeiros 15.000 caracteres devido ao tamanho. A análise pode não cobrir todo o conteúdo.]'
|
||||
}
|
||||
|
||||
// Stream response using SSE
|
||||
const encoder = new TextEncoder()
|
||||
let fullAnalysis = ''
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 8000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
// Send the analysis ID first
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'id', id: analysis.id })}\n\n`))
|
||||
|
||||
for await (const chunk of completion) {
|
||||
const content = chunk.choices[0]?.delta?.content
|
||||
if (content) {
|
||||
fullAnalysis += content
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'chunk', content })}\n\n`))
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary from first paragraph of analysis
|
||||
const summaryMatch = fullAnalysis.match(/## 1\. Resumo do Processo\n+([\s\S]*?)(?=\n## |\n#|$)/)
|
||||
const summary = summaryMatch
|
||||
? summaryMatch[1].trim().substring(0, 500)
|
||||
: fullAnalysis.substring(0, 300)
|
||||
|
||||
// Save completed analysis
|
||||
await prisma.processAnalysis.update({
|
||||
where: { id: analysis.id },
|
||||
data: {
|
||||
analysis: fullAnalysis,
|
||||
summary,
|
||||
status: 'DONE',
|
||||
},
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'done', id: analysis.id })}\n\n`))
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
console.error('OpenAI streaming error:', err)
|
||||
|
||||
// Refund credits on error
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { increment: CREDIT_COST } },
|
||||
})
|
||||
|
||||
await prisma.processAnalysis.update({
|
||||
where: { id: analysis.id },
|
||||
data: { status: 'ERROR', analysis: fullAnalysis || 'Erro durante a análise.' },
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'Erro ao gerar análise' })}\n\n`))
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Process analysis error:', error)
|
||||
return NextResponse.json({ error: 'Erro interno ao processar análise' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
26
src/app/api/auditoria/[id]/route.ts
Normal file
26
src/app/api/auditoria/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const audit = await prisma.contractAudit.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!audit) {
|
||||
return NextResponse.json({ error: 'Auditoria não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ audit })
|
||||
}
|
||||
144
src/app/api/auditoria/route.ts
Normal file
144
src/app/api/auditoria/route.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const audits = await prisma.contractAudit.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
riskScore: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ audits })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check credits
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } })
|
||||
if (!user || user.credits < 3) {
|
||||
return NextResponse.json({ error: 'Créditos insuficientes. A auditoria custa 3 créditos.' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { title, content } = body
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json({ error: 'Título e conteúdo são obrigatórios' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (title.length > 500) {
|
||||
return NextResponse.json({ error: "Título muito longo (máx 500 caracteres)" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (content.length > 100000) {
|
||||
return NextResponse.json({ error: "Conteúdo muito longo (máx 100.000 caracteres)" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (content.length < 100) {
|
||||
return NextResponse.json({ error: 'O contrato deve ter pelo menos 100 caracteres' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create audit record
|
||||
const audit = await prisma.contractAudit.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title,
|
||||
content,
|
||||
status: 'ANALYZING',
|
||||
},
|
||||
})
|
||||
|
||||
// Deduct credits
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: 3 } },
|
||||
})
|
||||
|
||||
// Call OpenAI for analysis
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Você é um advogado especialista em análise de contratos brasileiros. Analise o contrato fornecido e retorne APENAS um JSON válido (sem markdown, sem backticks) com a seguinte estrutura:
|
||||
{
|
||||
"riskScore": <número de 0 a 100, onde 100 é risco máximo>,
|
||||
"resumo": "<resumo executivo do contrato em 2-3 frases>",
|
||||
"clausulasAbusivas": [{"clausula": "<identificação>", "descricao": "<problema>", "gravidade": "alta|media|baixa"}],
|
||||
"inconsistencias": [{"item": "<identificação>", "descricao": "<problema>"}],
|
||||
"lacunas": [{"item": "<o que falta>", "descricao": "<por que é importante>"}],
|
||||
"riscos": [{"risco": "<identificação>", "descricao": "<detalhes>", "gravidade": "alta|media|baixa"}],
|
||||
"sugestoes": [{"sugestao": "<título>", "descricao": "<como implementar>"}],
|
||||
"pontosFavoraveis": [{"ponto": "<identificação>", "descricao": "<detalhes>"}]
|
||||
}`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Analise o seguinte contrato:\n\n${content.substring(0, 15000)}`
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 4000,
|
||||
})
|
||||
|
||||
const responseText = completion.choices[0]?.message?.content || '{}'
|
||||
let analysis
|
||||
try {
|
||||
analysis = JSON.parse(responseText)
|
||||
} catch {
|
||||
// Try to extract JSON from response
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
|
||||
analysis = jsonMatch ? JSON.parse(jsonMatch[0]) : { error: 'Falha ao parsear análise', raw: responseText }
|
||||
}
|
||||
|
||||
const riskScore = typeof analysis.riskScore === 'number' ? Math.min(100, Math.max(0, analysis.riskScore)) : 50
|
||||
|
||||
await prisma.contractAudit.update({
|
||||
where: { id: audit.id },
|
||||
data: {
|
||||
analysis,
|
||||
riskScore,
|
||||
status: 'DONE',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
audit: { ...audit, analysis, riskScore, status: 'DONE' },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('OpenAI error:', error)
|
||||
await prisma.contractAudit.update({
|
||||
where: { id: audit.id },
|
||||
data: { status: 'ERROR' },
|
||||
})
|
||||
|
||||
// Refund credits on error
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { increment: 3 } },
|
||||
})
|
||||
|
||||
return NextResponse.json({ error: 'Erro ao analisar contrato' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
62
src/app/api/chat/[chatId]/route.ts
Normal file
62
src/app/api/chat/[chatId]/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
|
||||
// Verify ownership
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: { id: chatId, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: 'Chat não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: { chatId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ messages })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ chatId: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { chatId } = await params
|
||||
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: { id: chatId, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: 'Chat não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.chat.delete({ where: { id: chatId } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
406
src/app/api/chat/route.ts
Normal file
406
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const LEGAL_SYSTEM_PROMPT =
|
||||
'Você é um assistente jurídico especialista EXCLUSIVAMENTE em Direito brasileiro. ' +
|
||||
'Você SOMENTE responde perguntas relacionadas a temas jurídicos e legais. ' +
|
||||
'Se o usuário perguntar sobre QUALQUER assunto que NÃO seja jurídico (tecnologia, culinária, matemática, programação, entretenimento, etc.), ' +
|
||||
'responda APENAS: "Desculpe, sou um assistente especializado exclusivamente em Direito brasileiro. Só posso ajudar com questões jurídicas e legais. Por favor, reformule sua pergunta dentro do âmbito jurídico." ' +
|
||||
'Não faça exceções. Não tente ajudar parcialmente com temas não jurídicos. ' +
|
||||
'Para questões jurídicas, responda com base na legislação vigente (CF/88, CPC, CC, CLT, CP, CDC, CTN, etc.), ' +
|
||||
'jurisprudência dos tribunais superiores (STF, STJ, TST) e doutrina. ' +
|
||||
'Cite artigos de lei quando relevante. Use linguagem técnica jurídica apropriada.'
|
||||
|
||||
const CREDIT_COST = 1
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Check credits
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { credits: true } })
|
||||
if (!user || user.credits < CREDIT_COST) {
|
||||
return new Response(JSON.stringify({ error: 'Créditos insuficientes' }), {
|
||||
status: 402,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { message, chatId } = body as { message: string; chatId?: string }
|
||||
// Validate input lengths
|
||||
if (message.length > 10000) {
|
||||
return new Response(JSON.stringify({ error: "Mensagem muito longa (máx 10.000 caracteres)" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!message?.trim()) {
|
||||
return new Response(JSON.stringify({ error: 'Mensagem vazia' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Create or get chat
|
||||
let chat: { id: string }
|
||||
if (chatId) {
|
||||
const existing = await prisma.chat.findFirst({ where: { id: chatId, userId } })
|
||||
if (!existing) {
|
||||
return new Response(JSON.stringify({ error: 'Chat não encontrado' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
chat = existing
|
||||
} else {
|
||||
// New chat — title from first message
|
||||
const title = message.length > 60 ? message.slice(0, 57) + '...' : message
|
||||
chat = await prisma.chat.create({ data: { userId, title } })
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await prisma.chatMessage.create({
|
||||
data: { chatId: chat.id, role: 'USER', content: message },
|
||||
})
|
||||
|
||||
// Load conversation history for context
|
||||
const history = await prisma.chatMessage.findMany({
|
||||
where: { chatId: chat.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { role: true, content: true },
|
||||
})
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: LEGAL_SYSTEM_PROMPT },
|
||||
...history.map((m) => ({
|
||||
role: (m.role === 'USER' ? 'user' : 'assistant') as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
})),
|
||||
]
|
||||
|
||||
// Call OpenAI
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
// Fallback: simulated streaming response for development
|
||||
return streamSimulated(chat.id, message, userId)
|
||||
}
|
||||
|
||||
try {
|
||||
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.4,
|
||||
max_tokens: 2048,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!openaiRes.ok) {
|
||||
const err = await openaiRes.text()
|
||||
console.error('OpenAI error:', err)
|
||||
return streamSimulated(chat.id, message, userId)
|
||||
}
|
||||
|
||||
// Stream SSE to client
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send chatId first
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ chatId: chat.id })}\n\n`))
|
||||
|
||||
const reader = openaiRes.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const delta = parsed.choices?.[0]?.delta?.content
|
||||
if (delta) {
|
||||
fullContent += delta
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ token: delta })}\n\n`)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Stream error:', e)
|
||||
}
|
||||
|
||||
// Save assistant message and deduct credits
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
chatId: chat.id,
|
||||
role: 'ASSISTANT',
|
||||
content: fullContent,
|
||||
tokens: Math.ceil(fullContent.length / 4),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: CREDIT_COST } },
|
||||
})
|
||||
|
||||
await prisma.usageLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'CHAT',
|
||||
tokens: Math.ceil(fullContent.length / 4),
|
||||
cost: 0,
|
||||
},
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('OpenAI fetch error:', error)
|
||||
return streamSimulated(chat.id, message, userId)
|
||||
}
|
||||
}
|
||||
|
||||
// Simulated streaming for dev without OpenAI key
|
||||
async function streamSimulated(chatId: string, userMessage: string, userId: string) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const simulatedResponse = generateSimulatedLegalResponse(userMessage)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ chatId })}\n\n`))
|
||||
|
||||
const words = simulatedResponse.split(' ')
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const token = (i === 0 ? '' : ' ') + words[i]
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ token })}\n\n`))
|
||||
await new Promise((r) => setTimeout(r, 30 + Math.random() * 40))
|
||||
}
|
||||
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: 'ASSISTANT',
|
||||
content: simulatedResponse,
|
||||
tokens: Math.ceil(simulatedResponse.length / 4),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: CREDIT_COST } },
|
||||
})
|
||||
|
||||
await prisma.usageLog.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'CHAT',
|
||||
tokens: Math.ceil(simulatedResponse.length / 4),
|
||||
cost: 0,
|
||||
},
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function generateSimulatedLegalResponse(question: string): string {
|
||||
const q = question.toLowerCase()
|
||||
|
||||
if (q.includes('contestação') && q.includes('jec')) {
|
||||
return `## Prazo para Contestação no JEC
|
||||
|
||||
No âmbito dos **Juizados Especiais Cíveis (JEC)**, regulados pela **Lei nº 9.099/95**, o prazo para apresentação de contestação segue regras próprias:
|
||||
|
||||
### Regra Geral
|
||||
A contestação deve ser apresentada **na audiência de instrução e julgamento**, conforme dispõe o **art. 30 da Lei 9.099/95**:
|
||||
|
||||
> *"Art. 30. A contestação, que será oral ou escrita, conterá toda matéria de defesa, exceto argüição de suspeição ou impedimento do Juiz, que se processará na forma da legislação em vigor."*
|
||||
|
||||
### Aspectos Importantes
|
||||
1. **Não há prazo em dias** como no procedimento comum do CPC (que é de 15 dias úteis, conforme art. 335 do CPC/2015)
|
||||
2. A defesa é concentrada na **audiência**, podendo ser oral ou escrita
|
||||
3. **Toda matéria de defesa** deve ser apresentada neste momento, sob pena de preclusão
|
||||
4. A **revelia** opera seus efeitos caso o réu não compareça à audiência (art. 20 da Lei 9.099/95)
|
||||
|
||||
### Jurisprudência
|
||||
O **STJ** tem entendimento pacífico de que nos Juizados Especiais, a contestação apresentada antes da audiência é válida, não havendo preclusão temporal antecipada (REsp 1.129.205/RJ).`
|
||||
}
|
||||
|
||||
if (q.includes('apelação') || q.includes('recurso de apelação')) {
|
||||
return `## Recurso de Apelação no CPC/2015
|
||||
|
||||
O recurso de apelação está disciplinado nos **arts. 1.009 a 1.014 do CPC/2015** e é o recurso cabível contra **sentenças** proferidas em primeiro grau.
|
||||
|
||||
### Cabimento
|
||||
Conforme o **art. 1.009 do CPC**:
|
||||
> *"Da sentença cabe apelação."*
|
||||
|
||||
### Prazo
|
||||
- **15 dias úteis** para interposição (art. 1.003, §5º, CPC)
|
||||
- **15 dias úteis** para contrarrazões (art. 1.010, §1º, CPC)
|
||||
|
||||
### Efeitos
|
||||
1. **Efeito devolutivo** — regra geral (art. 1.012, CPC)
|
||||
2. **Efeito suspensivo** — automático, salvo nas hipóteses do §1º do art. 1.012
|
||||
|
||||
### Exceções ao Efeito Suspensivo (art. 1.012, §1º)
|
||||
Começam a produzir efeitos imediatamente as sentenças que:
|
||||
- Homologam divisão ou demarcação de terras
|
||||
- Condenam a pagar alimentos
|
||||
- Julgam procedente pedido de instituição de arbitragem
|
||||
- Confirmam, concedem ou revogam tutela provisória
|
||||
- Decretam interdição
|
||||
|
||||
### Processamento
|
||||
1. Interposta perante o juízo *a quo*
|
||||
2. O juiz não faz juízo de admissibilidade (art. 1.010, §3º, CPC)
|
||||
3. Remetida diretamente ao tribunal competente
|
||||
4. Distribuída a um relator
|
||||
|
||||
### Jurisprudência Relevante
|
||||
O **STJ** consolidou que a apelação devolve ao tribunal o conhecimento da matéria impugnada, podendo o tribunal julgar questões de ordem pública ainda que não suscitadas (Súmula 456/STF, aplicável por analogia).`
|
||||
}
|
||||
|
||||
if (q.includes('habeas corpus')) {
|
||||
return `## Requisitos do Habeas Corpus
|
||||
|
||||
O **habeas corpus** é uma garantia constitucional prevista no **art. 5º, LXVIII, da CF/88** e regulamentado nos **arts. 647 a 667 do CPP**.
|
||||
|
||||
### Previsão Constitucional
|
||||
> *"Art. 5º, LXVIII - conceder-se-á habeas corpus sempre que alguém sofrer ou se achar ameaçado de sofrer violência ou coação em sua liberdade de locomoção, por ilegalidade ou abuso de poder."*
|
||||
|
||||
### Requisitos Essenciais
|
||||
1. **Legitimidade ativa** — qualquer pessoa pode impetrá-lo, em seu favor ou de outrem (art. 654, CPP)
|
||||
2. **Autoridade coatora** — identificação de quem pratica a coação ilegal
|
||||
3. **Paciente** — pessoa que sofre ou está ameaçada de sofrer a restrição
|
||||
4. **Constrangimento ilegal** à liberdade de locomoção
|
||||
|
||||
### Hipóteses de Cabimento (art. 648, CPP)
|
||||
A coação será considerada ilegal quando:
|
||||
- Não houver justa causa
|
||||
- Alguém estiver preso por mais tempo que a lei determina
|
||||
- Quem ordenar a coação não tiver competência
|
||||
- Houver cessado o motivo da coação
|
||||
- Não for admitida fiança nos casos previstos em lei
|
||||
- O processo for manifestamente nulo
|
||||
- Extinta a punibilidade
|
||||
|
||||
### Espécies
|
||||
- **Preventivo** (salvo-conduto) — quando há ameaça de coação
|
||||
- **Liberatório/Repressivo** — quando a coação já está consumada
|
||||
|
||||
### Competência
|
||||
- **STF** — quando o coator for Tribunal Superior (art. 102, I, *d*, CF)
|
||||
- **STJ** — quando o coator for tribunal sujeito à sua jurisdição (art. 105, I, *c*, CF)
|
||||
- **TJ/TRF** — quando o coator for juiz de primeiro grau
|
||||
- **Juiz de primeiro grau** — quando o coator for autoridade policial`
|
||||
}
|
||||
|
||||
return `## Análise Jurídica
|
||||
|
||||
Com base na sua consulta, apresento a seguinte análise:
|
||||
|
||||
### Fundamentação Legal
|
||||
A questão apresentada encontra respaldo na legislação brasileira vigente. Para uma análise mais precisa, seria necessário considerar:
|
||||
|
||||
1. **Legislação aplicável** — CF/88, CPC/2015, CC/2002 e legislação especial pertinente
|
||||
2. **Jurisprudência dominante** — posicionamento dos tribunais superiores (STF e STJ)
|
||||
3. **Doutrina majoritária** — entendimento dos principais juristas brasileiros
|
||||
|
||||
### Considerações Gerais
|
||||
O ordenamento jurídico brasileiro adota o **sistema de direito codificado** (*civil law*), onde as fontes primárias do direito são a lei, os costumes, a jurisprudência e os princípios gerais do direito (art. 4º da LINDB).
|
||||
|
||||
### Recomendação
|
||||
Para uma orientação mais específica e detalhada sobre sua questão, sugiro:
|
||||
- Reformular a pergunta com mais detalhes sobre o caso concreto
|
||||
- Indicar a área do direito (civil, penal, trabalhista, tributário, etc.)
|
||||
- Mencionar se há processo judicial em curso
|
||||
|
||||
*Nota: Esta é uma orientação jurídica geral e não substitui a consulta presencial a um advogado.*`
|
||||
}
|
||||
|
||||
// GET: list chats for the authenticated user
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: { content: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(JSON.stringify({ chats }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
44
src/app/api/checkout/route.ts
Normal file
44
src/app/api/checkout/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { stripe } from "@/lib/stripe"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { priceId } = await req.json()
|
||||
|
||||
if (!priceId || typeof priceId !== "string") {
|
||||
return NextResponse.json({ error: "Price ID inválido" }, { status: 400 })
|
||||
}
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_types: ["card"],
|
||||
customer_email: session.user.email,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: "required",
|
||||
metadata: { userId: session.user.id },
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url })
|
||||
} catch (error: any) {
|
||||
console.error("Stripe checkout error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Erro ao criar sessão de checkout" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
50
src/app/api/documents/[id]/route.ts
Normal file
50
src/app/api/documents/[id]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: 'Documento não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ document })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: 'Documento não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.document.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
358
src/app/api/documents/generate/route.ts
Normal file
358
src/app/api/documents/generate/route.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const SYSTEM_PROMPT = `Você é um advogado sênior brasileiro com mais de 30 anos de experiência em todas as áreas do direito. Você domina completamente a legislação brasileira, incluindo mas não limitado a:
|
||||
|
||||
- Constituição Federal de 1988 (CF/88)
|
||||
- Código de Processo Civil (CPC - Lei nº 13.105/2015)
|
||||
- Código Civil (CC - Lei nº 10.406/2002)
|
||||
- Código Penal (CP - Decreto-Lei nº 2.848/1940)
|
||||
- Código de Processo Penal (CPP - Decreto-Lei nº 3.689/1941)
|
||||
- Consolidação das Leis do Trabalho (CLT)
|
||||
- Código de Defesa do Consumidor (CDC - Lei nº 8.078/1990)
|
||||
- Código Tributário Nacional (CTN - Lei nº 5.172/1966)
|
||||
- Estatuto da Criança e do Adolescente (ECA)
|
||||
- Lei de Execução Penal (LEP)
|
||||
- Lei Maria da Penha (Lei nº 11.340/2006)
|
||||
- Lei de Improbidade Administrativa (Lei nº 8.429/1992)
|
||||
|
||||
INSTRUÇÕES RIGOROSAS:
|
||||
|
||||
1. ESTRUTURA: Toda peça deve seguir a estrutura formal brasileira:
|
||||
- Endereçamento ao juízo competente (em caixa alta)
|
||||
- Qualificação completa das partes
|
||||
- DOS FATOS (narrativa clara e cronológica)
|
||||
- DO DIREITO / DOS FUNDAMENTOS JURÍDICOS (argumentação robusta)
|
||||
- DOS PEDIDOS (numerados e específicos)
|
||||
- Requerimentos finais
|
||||
- Valor da causa (quando aplicável)
|
||||
- Local, data e assinatura
|
||||
|
||||
2. CITAÇÕES LEGAIS: Cite artigos específicos de leis reais. Use jurisprudência quando relevante (STF, STJ, TJs). Formate citações em itálico.
|
||||
|
||||
3. LINGUAGEM: Use português jurídico formal. Trate o juiz como "Excelentíssimo(a) Senhor(a) Doutor(a) Juiz(a)" ou "Meritíssimo". Use "data venia", "ad argumentandum tantum", e demais expressões latinas quando apropriado.
|
||||
|
||||
4. QUALIDADE: A peça deve ser completa, profissional e pronta para protocolar. Não deixe campos em branco - use [COMPLETAR] apenas para dados específicos do cliente que não foram fornecidos.
|
||||
|
||||
5. FORMATAÇÃO: Use parágrafos numerados, seções em negrito/caixa alta, e espaçamento adequado para leitura jurídica.
|
||||
|
||||
6. Nunca mencione que é uma IA. Escreva como um advogado real escreveria.`
|
||||
|
||||
function buildUserPrompt(type: string, area: string, details: Record<string, string>): string {
|
||||
const typeNames: Record<string, string> = {
|
||||
'peticao-inicial': 'Petição Inicial',
|
||||
'contestacao': 'Contestação',
|
||||
'apelacao-civel': 'Apelação Cível',
|
||||
'apelacao-criminal': 'Apelação Criminal',
|
||||
'recurso': 'Recurso',
|
||||
'contrato': 'Contrato',
|
||||
'parecer': 'Parecer Jurídico',
|
||||
'impugnacao': 'Impugnação',
|
||||
'habeas-corpus': 'Habeas Corpus',
|
||||
'mandado-seguranca': 'Mandado de Segurança',
|
||||
'embargo': 'Embargo',
|
||||
'recurso-especial': 'Recurso Especial',
|
||||
'agravo': 'Agravo',
|
||||
'outros': 'Peça Jurídica',
|
||||
}
|
||||
|
||||
const areaNames: Record<string, string> = {
|
||||
'civil': 'Direito Civil',
|
||||
'trabalhista': 'Direito Trabalhista',
|
||||
'penal': 'Direito Penal',
|
||||
'tributario': 'Direito Tributário',
|
||||
'familia': 'Direito de Família',
|
||||
'empresarial': 'Direito Empresarial',
|
||||
'consumidor': 'Direito do Consumidor',
|
||||
'administrativo': 'Direito Administrativo',
|
||||
}
|
||||
|
||||
const typeName = typeNames[type] || type
|
||||
const areaName = areaNames[area] || area
|
||||
|
||||
let prompt = `Elabore uma ${typeName} completa na área de ${areaName}.\n\n`
|
||||
|
||||
if (details.autor) prompt += `AUTOR/REQUERENTE: ${details.autor}\n`
|
||||
if (details.reu) prompt += `RÉU/REQUERIDO: ${details.reu}\n`
|
||||
if (details.fatos) prompt += `\nFATOS:\n${details.fatos}\n`
|
||||
if (details.fundamentos) prompt += `\nFUNDAMENTOS JURÍDICOS RELEVANTES:\n${details.fundamentos}\n`
|
||||
if (details.pedidos) prompt += `\nPEDIDOS PRETENDIDOS:\n${details.pedidos}\n`
|
||||
if (details.contexto) prompt += `\nCONTEXTO ADICIONAL:\n${details.contexto}\n`
|
||||
|
||||
prompt += `\nElabore a peça completa, profissional e pronta para protocolar.`
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: 'Não autorizado' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Check credits
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { credits: true },
|
||||
})
|
||||
|
||||
if (!user || user.credits < 1) {
|
||||
return new Response(JSON.stringify({ error: 'Créditos insuficientes' }), {
|
||||
status: 402,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { type, area, details } = body
|
||||
|
||||
if (!type || !area || !details) {
|
||||
return new Response(JSON.stringify({ error: 'Dados incompletos' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt(type, area, details)
|
||||
|
||||
// Create document record
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: type.toUpperCase().replace(/-/g, '_'),
|
||||
title: `${type} - ${area}`,
|
||||
prompt: userPrompt,
|
||||
content: '',
|
||||
area: area.toUpperCase(),
|
||||
status: 'GENERATING',
|
||||
},
|
||||
})
|
||||
|
||||
// Deduct credit
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { decrement: 1 } },
|
||||
})
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
// Fallback: generate a mock response for development
|
||||
const encoder = new TextEncoder()
|
||||
const mockContent = generateMockDocument(type, area, details)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const words = mockContent.split(' ')
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const chunk = (i === 0 ? '' : ' ') + words[i]
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: chunk, documentId: document.id })}\n\n`))
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
}
|
||||
|
||||
// Update document
|
||||
await prisma.document.update({
|
||||
where: { id: document.id },
|
||||
data: {
|
||||
content: mockContent,
|
||||
wordCount: mockContent.split(/\s+/).length,
|
||||
status: 'COMPLETED',
|
||||
tokens: mockContent.length,
|
||||
},
|
||||
})
|
||||
|
||||
// Log usage
|
||||
await prisma.usageLog.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: 'DOCUMENT',
|
||||
tokens: mockContent.length,
|
||||
cost: 0,
|
||||
},
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true, documentId: document.id })}\n\n`))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Real OpenAI call with streaming
|
||||
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.4,
|
||||
max_tokens: 8000,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!openaiRes.ok) {
|
||||
await prisma.document.update({
|
||||
where: { id: document.id },
|
||||
data: { status: 'ERROR' },
|
||||
})
|
||||
// Refund credit
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { credits: { increment: 1 } },
|
||||
})
|
||||
return new Response(JSON.stringify({ error: 'Erro ao gerar documento' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let totalTokens = 0
|
||||
|
||||
const transformStream = new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
const text = new TextDecoder().decode(chunk)
|
||||
const lines = text.split('\n').filter(line => line.startsWith('data: '))
|
||||
|
||||
for (const line of lines) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') {
|
||||
// Save final document
|
||||
const wordCount = fullContent.split(/\s+/).filter(Boolean).length
|
||||
await prisma.document.update({
|
||||
where: { id: document.id },
|
||||
data: {
|
||||
content: fullContent,
|
||||
wordCount,
|
||||
status: 'COMPLETED',
|
||||
tokens: totalTokens,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.usageLog.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
type: 'DOCUMENT',
|
||||
tokens: totalTokens,
|
||||
cost: (totalTokens / 1000) * 0.005,
|
||||
},
|
||||
})
|
||||
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ done: true, documentId: document.id })}\n\n`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
fullContent += content
|
||||
totalTokens += content.length // Approximate
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content, documentId: document.id })}\n\n`))
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed chunks
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const responseStream = openaiRes.body!.pipeThrough(transformStream)
|
||||
|
||||
return new Response(responseStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Document generation error:', error)
|
||||
return new Response(JSON.stringify({ error: 'Erro interno do servidor' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockDocument(type: string, area: string, details: Record<string, string>): string {
|
||||
const autor = details.autor || '[NOME DO AUTOR]'
|
||||
const reu = details.reu || '[NOME DO RÉU]'
|
||||
|
||||
return `EXCELENTÍSSIMO(A) SENHOR(A) DOUTOR(A) JUIZ(A) DE DIREITO DA ___ VARA CÍVEL DA COMARCA DE [CIDADE] - ESTADO DE [UF]
|
||||
|
||||
${autor}, brasileiro(a), [estado civil], [profissão], portador(a) do RG nº [COMPLETAR] e CPF nº [COMPLETAR], residente e domiciliado(a) na [endereço completo], por intermédio de seu(sua) advogado(a) que esta subscreve, com escritório profissional na [endereço do escritório], onde recebe intimações, vem, respeitosamente, perante Vossa Excelência, com fundamento nos artigos 319 e seguintes do Código de Processo Civil (Lei nº 13.105/2015), propor a presente
|
||||
|
||||
PETIÇÃO INICIAL
|
||||
|
||||
em face de ${reu}, [qualificação completa do réu], pelos fatos e fundamentos a seguir expostos:
|
||||
|
||||
I - DOS FATOS
|
||||
|
||||
${details.fatos || '1. [Narrar os fatos de forma clara e cronológica, indicando datas, locais e circunstâncias relevantes.]'}
|
||||
|
||||
2. Conforme restará demonstrado, os fatos narrados configuram clara violação aos direitos do(a) Autor(a), ensejando a tutela jurisdicional ora pleiteada.
|
||||
|
||||
3. Diante da situação narrada, não restou alternativa ao(à) Autor(a) senão buscar a tutela do Poder Judiciário para ver seus direitos resguardados.
|
||||
|
||||
II - DO DIREITO
|
||||
|
||||
${details.fundamentos || `4. O presente caso encontra amparo no ordenamento jurídico brasileiro, especialmente nos seguintes dispositivos legais:
|
||||
|
||||
5. A Constituição Federal de 1988, em seu artigo 5º, inciso XXXV, assegura que "a lei não excluirá da apreciação do Poder Judiciário lesão ou ameaça a direito". Trata-se do princípio da inafastabilidade da jurisdição, que garante ao(à) Autor(a) o direito de buscar a tutela jurisdicional.
|
||||
|
||||
6. O Código Civil (Lei nº 10.406/2002), em seu artigo 186, estabelece que "aquele que, por ação ou omissão voluntária, negligência ou imprudência, violar direito e causar dano a outrem, ainda que exclusivamente moral, comete ato ilícito".
|
||||
|
||||
7. Nesse sentido, o artigo 927 do mesmo diploma legal determina que "aquele que, por ato ilícito (arts. 186 e 187), causar dano a outrem, fica obrigado a repará-lo".
|
||||
|
||||
8. A jurisprudência do Superior Tribunal de Justiça é pacífica nesse sentido:
|
||||
|
||||
"CIVIL E PROCESSUAL CIVIL. RESPONSABILIDADE CIVIL. DANO MORAL. CONFIGURAÇÃO. O dano moral prescinde de prova quando decorre de fato por si só lesivo à dignidade da pessoa humana." (STJ, AgRg no AREsp nº XXXXX/SP, Rel. Min. XXXXX, DJe XX/XX/XXXX).`}
|
||||
|
||||
III - DOS PEDIDOS
|
||||
|
||||
Ante o exposto, requer a Vossa Excelência:
|
||||
|
||||
${details.pedidos || `a) A citação do(a) Réu(Ré) para, querendo, apresentar contestação no prazo legal, sob pena de revelia e confissão quanto à matéria de fato;
|
||||
|
||||
b) A procedência total dos pedidos formulados nesta inicial;
|
||||
|
||||
c) A condenação do(a) Réu(Ré) ao pagamento de indenização por danos morais, em valor a ser arbitrado por Vossa Excelência, observados os princípios da razoabilidade e proporcionalidade;
|
||||
|
||||
d) A condenação do(a) Réu(Ré) ao pagamento das custas processuais e honorários advocatícios, nos termos do artigo 85 do CPC;`}
|
||||
|
||||
e) A produção de todas as provas admitidas em direito, especialmente documental, testemunhal, pericial e depoimento pessoal do(a) Réu(Ré), sob pena de confesso(a).
|
||||
|
||||
Dá-se à causa o valor de R$ [COMPLETAR] ([valor por extenso]).
|
||||
|
||||
Termos em que,
|
||||
Pede deferimento.
|
||||
|
||||
[Cidade], [data].
|
||||
|
||||
_______________________________
|
||||
[NOME DO ADVOGADO]
|
||||
OAB/[UF] nº [COMPLETAR]`
|
||||
}
|
||||
54
src/app/api/documents/route.ts
Normal file
54
src/app/api/documents/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const type = searchParams.get('type')
|
||||
const area = searchParams.get('area')
|
||||
const search = searchParams.get('search')
|
||||
const sort = searchParams.get('sort') || 'newest'
|
||||
const dateFrom = searchParams.get('dateFrom')
|
||||
const dateTo = searchParams.get('dateTo')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = { userId: session.user.id }
|
||||
|
||||
if (type) where.type = type
|
||||
if (area) where.area = area
|
||||
if (search) where.title = { contains: search }
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {}
|
||||
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
|
||||
if (dateTo) where.createdAt.lte = new Date(dateTo + 'T23:59:59.999Z')
|
||||
}
|
||||
|
||||
let orderBy: Record<string, string> = { createdAt: 'desc' }
|
||||
if (sort === 'oldest') orderBy = { createdAt: 'asc' }
|
||||
else if (sort === 'type') orderBy = { type: 'asc' }
|
||||
|
||||
const documents = await prisma.document.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
area: true,
|
||||
status: true,
|
||||
wordCount: true,
|
||||
tokens: true,
|
||||
cost: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ documents })
|
||||
}
|
||||
345
src/app/api/export/docx/route.ts
Normal file
345
src/app/api/export/docx/route.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
HeadingLevel,
|
||||
Footer,
|
||||
PageNumber,
|
||||
TabStopPosition,
|
||||
TabStopType,
|
||||
} from 'docx'
|
||||
|
||||
// ─── Markdown → docx Paragraphs ─────────────────────────────────
|
||||
|
||||
function parseMarkdownToParagraphs(markdown: string): Paragraph[] {
|
||||
const lines = markdown.split('\n')
|
||||
const paragraphs: Paragraph[] = []
|
||||
|
||||
let inList = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Skip empty lines
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Headings
|
||||
const h1Match = line.match(/^#\s+(.+)/)
|
||||
const h2Match = line.match(/^##\s+(.+)/)
|
||||
const h3Match = line.match(/^###\s+(.+)/)
|
||||
const h4Match = line.match(/^####\s+(.+)/)
|
||||
|
||||
if (h1Match) {
|
||||
paragraphs.push(new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
spacing: { before: 360, after: 200 },
|
||||
children: parseInlineFormatting(h1Match[1], { bold: true, size: 32, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
if (h2Match) {
|
||||
paragraphs.push(new Paragraph({
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 300, after: 160 },
|
||||
children: parseInlineFormatting(h2Match[1], { bold: true, size: 28, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
if (h3Match) {
|
||||
paragraphs.push(new Paragraph({
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
spacing: { before: 240, after: 120 },
|
||||
children: parseInlineFormatting(h3Match[1], { bold: true, size: 26, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
if (h4Match) {
|
||||
paragraphs.push(new Paragraph({
|
||||
spacing: { before: 200, after: 100 },
|
||||
children: parseInlineFormatting(h4Match[1], { bold: true, size: 24, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^[-*_]{3,}\s*$/.test(line.trim())) {
|
||||
paragraphs.push(new Paragraph({
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: '999999' } },
|
||||
spacing: { before: 200, after: 200 },
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Bullet list
|
||||
const bulletMatch = line.match(/^[\s]*[-*+]\s+(.+)/)
|
||||
if (bulletMatch) {
|
||||
paragraphs.push(new Paragraph({
|
||||
bullet: { level: 0 },
|
||||
spacing: { before: 60, after: 60 },
|
||||
children: parseInlineFormatting(bulletMatch[1], { size: 24, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Numbered list
|
||||
const numMatch = line.match(/^[\s]*\d+[.)]\s+(.+)/)
|
||||
if (numMatch) {
|
||||
paragraphs.push(new Paragraph({
|
||||
bullet: { level: 0 },
|
||||
spacing: { before: 60, after: 60 },
|
||||
children: parseInlineFormatting(numMatch[1], { size: 24, font: 'Times New Roman' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
const quoteMatch = line.match(/^>\s*(.+)/)
|
||||
if (quoteMatch) {
|
||||
paragraphs.push(new Paragraph({
|
||||
indent: { left: 720 },
|
||||
spacing: { before: 120, after: 120 },
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 3, color: '0D9488' } },
|
||||
children: parseInlineFormatting(quoteMatch[1], { italics: true, size: 24, font: 'Times New Roman', color: '555555' }),
|
||||
}))
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
paragraphs.push(new Paragraph({
|
||||
spacing: { before: 120, after: 120, line: 360 },
|
||||
alignment: AlignmentType.JUSTIFIED,
|
||||
children: parseInlineFormatting(line, { size: 24, font: 'Times New Roman' }),
|
||||
}))
|
||||
}
|
||||
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
interface InlineDefaults {
|
||||
bold?: boolean
|
||||
italics?: boolean
|
||||
size?: number
|
||||
font?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function parseInlineFormatting(text: string, defaults: InlineDefaults): TextRun[] {
|
||||
const runs: TextRun[] = []
|
||||
// Match bold+italic, bold, italic, or plain text
|
||||
const regex = /(\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|\*(.+?)\*|([^*]+))/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match[2]) {
|
||||
// Bold + Italic
|
||||
runs.push(new TextRun({
|
||||
text: match[2],
|
||||
bold: true,
|
||||
italics: true,
|
||||
size: defaults.size || 24,
|
||||
font: defaults.font || 'Times New Roman',
|
||||
color: defaults.color,
|
||||
}))
|
||||
} else if (match[3]) {
|
||||
// Bold
|
||||
runs.push(new TextRun({
|
||||
text: match[3],
|
||||
bold: true,
|
||||
italics: defaults.italics,
|
||||
size: defaults.size || 24,
|
||||
font: defaults.font || 'Times New Roman',
|
||||
color: defaults.color,
|
||||
}))
|
||||
} else if (match[4]) {
|
||||
// Italic
|
||||
runs.push(new TextRun({
|
||||
text: match[4],
|
||||
bold: defaults.bold,
|
||||
italics: true,
|
||||
size: defaults.size || 24,
|
||||
font: defaults.font || 'Times New Roman',
|
||||
color: defaults.color,
|
||||
}))
|
||||
} else if (match[5]) {
|
||||
// Plain text
|
||||
runs.push(new TextRun({
|
||||
text: match[5],
|
||||
bold: defaults.bold,
|
||||
italics: defaults.italics,
|
||||
size: defaults.size || 24,
|
||||
font: defaults.font || 'Times New Roman',
|
||||
color: defaults.color,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return runs.length ? runs : [new TextRun({
|
||||
text,
|
||||
bold: defaults.bold,
|
||||
italics: defaults.italics,
|
||||
size: defaults.size || 24,
|
||||
font: defaults.font || 'Times New Roman',
|
||||
color: defaults.color,
|
||||
})]
|
||||
}
|
||||
|
||||
// ─── API Route ───────────────────────────────────────────────────
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, content } = await req.json()
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json({ error: 'Título e conteúdo são obrigatórios' }, { status: 400 })
|
||||
}
|
||||
|
||||
const bodyParagraphs = parseMarkdownToParagraphs(content)
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
font: 'Times New Roman',
|
||||
size: 24,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: { line: 360 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: 1440,
|
||||
bottom: 1440,
|
||||
left: 1440,
|
||||
right: 1440,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
default: {
|
||||
options: {
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.LEFT,
|
||||
spacing: { after: 100 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: 'LexMind',
|
||||
bold: true,
|
||||
size: 28,
|
||||
font: 'Times New Roman',
|
||||
color: '0D9488',
|
||||
}),
|
||||
new TextRun({
|
||||
text: ' — Inteligência Artificial Jurídica',
|
||||
size: 20,
|
||||
font: 'Times New Roman',
|
||||
color: '6B7280',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 2, color: '0D9488' },
|
||||
},
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
footers: {
|
||||
default: {
|
||||
options: {
|
||||
children: [
|
||||
new Paragraph({
|
||||
border: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
},
|
||||
spacing: { before: 100 },
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: 'Documento gerado por LexMind — lexmind.adv.br',
|
||||
size: 16,
|
||||
font: 'Times New Roman',
|
||||
color: '9CA3AF',
|
||||
}),
|
||||
new TextRun({
|
||||
text: ' Página ',
|
||||
size: 16,
|
||||
font: 'Times New Roman',
|
||||
color: '9CA3AF',
|
||||
}),
|
||||
new TextRun({
|
||||
children: [PageNumber.CURRENT],
|
||||
size: 16,
|
||||
font: 'Times New Roman',
|
||||
color: '9CA3AF',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
children: [
|
||||
// Document Title
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 200, after: 400 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title,
|
||||
bold: true,
|
||||
size: 36,
|
||||
font: 'Times New Roman',
|
||||
color: '111827',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
// Separator
|
||||
new Paragraph({
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'D1D5DB' },
|
||||
},
|
||||
spacing: { after: 300 },
|
||||
}),
|
||||
// Body
|
||||
...bodyParagraphs,
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(title)}.docx"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('DOCX export error:', error)
|
||||
return NextResponse.json({ error: 'Erro ao gerar documento Word' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
72
src/app/api/jurisprudencia/route.ts
Normal file
72
src/app/api/jurisprudencia/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const search = searchParams.get('search') || ''
|
||||
const tribunal = searchParams.get('tribunal') || ''
|
||||
const area = searchParams.get('area') || ''
|
||||
const relator = searchParams.get('relator') || ''
|
||||
const dateFrom = searchParams.get('dateFrom') || ''
|
||||
const dateTo = searchParams.get('dateTo') || ''
|
||||
const page = Math.max(1, Math.min(1000, parseInt(searchParams.get('page') || '1', 10) || 1))
|
||||
const perPage = Math.max(1, Math.min(50, parseInt(searchParams.get('perPage') || '10', 10) || 10))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {}
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ ementa: { contains: search, mode: 'insensitive' } },
|
||||
{ numero: { contains: search, mode: 'insensitive' } },
|
||||
{ relator: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
|
||||
if (tribunal) {
|
||||
where.tribunal = tribunal
|
||||
}
|
||||
|
||||
if (area) {
|
||||
where.area = area
|
||||
}
|
||||
|
||||
if (relator) {
|
||||
where.relator = { contains: relator, mode: 'insensitive' }
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
// data is stored as string "YYYY-MM-DD"
|
||||
if (dateFrom) {
|
||||
where.data = { ...(where.data || {}), gte: dateFrom }
|
||||
}
|
||||
if (dateTo) {
|
||||
where.data = { ...(where.data || {}), lte: dateTo }
|
||||
}
|
||||
}
|
||||
|
||||
const [total, results] = await Promise.all([
|
||||
prisma.jurisprudencia.count({ where }),
|
||||
prisma.jurisprudencia.findMany({
|
||||
where,
|
||||
orderBy: { data: 'desc' },
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
total,
|
||||
page,
|
||||
perPage,
|
||||
totalPages: Math.ceil(total / perPage),
|
||||
})
|
||||
}
|
||||
117
src/app/api/jurisprudencia/search/route.ts
Normal file
117
src/app/api/jurisprudencia/search/route.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
interface ExtractedTerms {
|
||||
keywords: string[]
|
||||
tribunal?: string
|
||||
area?: string
|
||||
relator?: string
|
||||
dateRange?: { from?: string; to?: string }
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { query } = body
|
||||
|
||||
if (!query || typeof query !== 'string') {
|
||||
return NextResponse.json({ error: 'Query é obrigatória' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Use OpenAI to extract structured legal search terms
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Você é um assistente jurídico brasileiro especializado em pesquisa de jurisprudência.
|
||||
Dado uma consulta em linguagem natural, extraia termos de busca estruturados.
|
||||
|
||||
Responda APENAS com JSON válido no formato:
|
||||
{
|
||||
"keywords": ["termo1", "termo2"],
|
||||
"tribunal": "STF" | "STJ" | "TST" | "TRF1" | ... | null,
|
||||
"area": "CIVIL" | "TRABALHISTA" | "PENAL" | "TRIBUTARIO" | "FAMILIA" | "EMPRESARIAL" | "CONSUMIDOR" | "ADMINISTRATIVO" | null,
|
||||
"relator": "nome do relator" | null,
|
||||
"dateRange": { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" } | null
|
||||
}
|
||||
|
||||
Extraia o máximo de termos jurídicos relevantes. Inclua sinônimos e termos técnicos.
|
||||
Se o usuário mencionar um tribunal específico, inclua-o.
|
||||
Se mencionar uma área do direito, identifique-a.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: query,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
let extracted: ExtractedTerms
|
||||
try {
|
||||
const raw = completion.choices[0].message.content || '{}'
|
||||
// Strip markdown code fences if present
|
||||
const cleaned = raw.replace(/```json?\n?/g, '').replace(/```/g, '').trim()
|
||||
extracted = JSON.parse(cleaned)
|
||||
} catch {
|
||||
extracted = { keywords: query.split(/\s+/).filter((w) => w.length > 2) }
|
||||
}
|
||||
|
||||
// Build search queries from extracted keywords
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {}
|
||||
|
||||
if (extracted.keywords.length > 0) {
|
||||
// Search ementa for any of the keywords
|
||||
where.OR = extracted.keywords.map((kw) => ({
|
||||
ementa: { contains: kw, mode: 'insensitive' },
|
||||
}))
|
||||
}
|
||||
|
||||
if (extracted.tribunal) {
|
||||
where.tribunal = extracted.tribunal
|
||||
}
|
||||
|
||||
if (extracted.area) {
|
||||
where.area = extracted.area
|
||||
}
|
||||
|
||||
if (extracted.relator) {
|
||||
where.relator = { contains: extracted.relator, mode: 'insensitive' }
|
||||
}
|
||||
|
||||
if (extracted.dateRange) {
|
||||
if (extracted.dateRange.from) {
|
||||
where.data = { ...(where.data || {}), gte: extracted.dateRange.from }
|
||||
}
|
||||
if (extracted.dateRange.to) {
|
||||
where.data = { ...(where.data || {}), lte: extracted.dateRange.to }
|
||||
}
|
||||
}
|
||||
|
||||
const [total, results] = await Promise.all([
|
||||
prisma.jurisprudencia.count({ where }),
|
||||
prisma.jurisprudencia.findMany({
|
||||
where,
|
||||
orderBy: { data: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
total,
|
||||
extractedTerms: extracted,
|
||||
aiQuery: query,
|
||||
})
|
||||
}
|
||||
61
src/app/api/keys/[id]/route.ts
Normal file
61
src/app/api/keys/[id]/route.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// PATCH /api/keys/[id] — toggle active status (revoke/reactivate)
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const key = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'Chave não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const active = typeof body.active === 'boolean' ? body.active : !key.active
|
||||
|
||||
const updated = await prisma.apiKey.update({
|
||||
where: { id },
|
||||
data: { active },
|
||||
select: { id: true, name: true, active: true },
|
||||
})
|
||||
|
||||
return NextResponse.json(updated)
|
||||
}
|
||||
|
||||
// DELETE /api/keys/[id] — permanently delete a key
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const key = await prisma.apiKey.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'Chave não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.apiKey.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ message: 'Chave revogada e excluída' })
|
||||
}
|
||||
87
src/app/api/keys/route.ts
Normal file
87
src/app/api/keys/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { randomBytes, createHash } from 'crypto'
|
||||
|
||||
// GET /api/keys — list user's API keys
|
||||
export async function GET() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Mask keys — only show prefix + last 4 chars
|
||||
const masked = keys.map((k) => ({
|
||||
...k,
|
||||
key: k.key.slice(0, 8) + '••••••••' + k.key.slice(-4),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ keys: masked })
|
||||
}
|
||||
|
||||
// POST /api/keys — create a new API key
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name || name.length < 2) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nome da chave é obrigatório (mínimo 2 caracteres)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check key limit (max 5)
|
||||
const count = await prisma.apiKey.count({
|
||||
where: { userId: session.user.id },
|
||||
})
|
||||
|
||||
if (count >= 5) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Limite de 5 chaves atingido. Revogue uma chave existente.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate key: jur_ prefix + 40 random hex chars
|
||||
const rawKey = 'jur_' + randomBytes(20).toString('hex')
|
||||
const hashedKey = createHash('sha256').update(rawKey).digest('hex')
|
||||
|
||||
const apiKey = await prisma.apiKey.create({
|
||||
data: {
|
||||
name,
|
||||
key: hashedKey,
|
||||
userId: session.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Return the raw key ONLY on creation (never stored/shown again)
|
||||
return NextResponse.json({
|
||||
...apiKey,
|
||||
key: rawKey,
|
||||
message: 'Copie a chave agora. Ela não será exibida novamente.',
|
||||
}, { status: 201 })
|
||||
}
|
||||
65
src/app/api/prazos/[id]/route.ts
Normal file
65
src/app/api/prazos/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const prazo = await prisma.prazo.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!prazo) {
|
||||
return NextResponse.json({ error: 'Prazo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updated = await prisma.prazo.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.processNumber !== undefined && { processNumber: body.processNumber }),
|
||||
...(body.court !== undefined && { court: body.court }),
|
||||
...(body.deadline !== undefined && { deadline: new Date(body.deadline) }),
|
||||
...(body.alertDays !== undefined && { alertDays: body.alertDays }),
|
||||
...(body.status !== undefined && { status: body.status }),
|
||||
...(body.priority !== undefined && { priority: body.priority }),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ prazo: updated })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const prazo = await prisma.prazo.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!prazo) {
|
||||
return NextResponse.json({ error: 'Prazo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.prazo.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
64
src/app/api/prazos/route.ts
Normal file
64
src/app/api/prazos/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const status = searchParams.get('status')
|
||||
const priority = searchParams.get('priority')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = { userId: session.user.id }
|
||||
if (status) where.status = status
|
||||
if (priority) where.priority = priority
|
||||
|
||||
const prazos = await prisma.prazo.findMany({
|
||||
where,
|
||||
orderBy: { deadline: 'asc' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ prazos })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { title, description, processNumber, court, deadline, alertDays, priority } = body
|
||||
|
||||
// Input validation
|
||||
if (typeof title !== "string" || title.length > 500) {
|
||||
return NextResponse.json({ error: "Título inválido (máx 500 caracteres)" }, { status: 400 })
|
||||
}
|
||||
if (description && typeof description === "string" && description.length > 5000) {
|
||||
return NextResponse.json({ error: "Descrição muito longa (máx 5000 caracteres)" }, { status: 400 })
|
||||
}
|
||||
if (!title || !deadline) {
|
||||
return NextResponse.json({ error: 'Título e prazo são obrigatórios' }, { status: 400 })
|
||||
}
|
||||
|
||||
const prazo = await prisma.prazo.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title,
|
||||
description: description || null,
|
||||
processNumber: processNumber || null,
|
||||
court: court || null,
|
||||
deadline: new Date(deadline),
|
||||
alertDays: alertDays || 3,
|
||||
priority: priority || 'MEDIA',
|
||||
status: 'PENDENTE',
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ prazo }, { status: 201 })
|
||||
}
|
||||
128
src/app/api/processos/[id]/atualizar/route.ts
Normal file
128
src/app/api/processos/[id]/atualizar/route.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buscarProcessoTribunal, isError } from '@/lib/tribunal-client'
|
||||
|
||||
// POST /api/processos/[id]/atualizar
|
||||
// Busca dados em tempo real do tribunal via scraping
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Buscar processo no banco
|
||||
const processo = await prisma.processoMonitorado.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
if (!processo) {
|
||||
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Buscar dados em tempo real via tribunal-api
|
||||
console.log(`[Atualizar] Buscando ${processo.numeroProcesso} via tribunal-api...`)
|
||||
const resultado = await buscarProcessoTribunal(processo.numeroProcesso)
|
||||
|
||||
if (isError(resultado)) {
|
||||
return NextResponse.json({
|
||||
error: resultado.erro,
|
||||
codigo: resultado.codigo,
|
||||
detalhes: resultado.detalhes,
|
||||
}, { status: resultado.codigo === 'PROCESSO_NAO_ENCONTRADO' ? 404 : 503 })
|
||||
}
|
||||
|
||||
// Atualizar dados do processo no banco
|
||||
const updated = await prisma.processoMonitorado.update({
|
||||
where: { id },
|
||||
data: {
|
||||
vara: resultado.vara || processo.vara,
|
||||
comarca: resultado.comarca || processo.comarca,
|
||||
classe: resultado.classe || processo.classe,
|
||||
assunto: resultado.assunto || processo.assunto,
|
||||
ultimaAtualizacao: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Salvar novos andamentos se existirem
|
||||
let novosAndamentos = 0
|
||||
if (resultado.movimentos && resultado.movimentos.length > 0) {
|
||||
for (let i = 0; i < resultado.movimentos.length; i++) {
|
||||
const mov = resultado.movimentos[i]
|
||||
|
||||
// Converter data DD/MM/YYYY para Date
|
||||
const partes = mov.data.split('/')
|
||||
if (partes.length !== 3) continue
|
||||
|
||||
const [dia, mes, ano] = partes
|
||||
const dataHora = new Date(`${ano}-${mes}-${dia}T12:00:00Z`)
|
||||
|
||||
if (isNaN(dataHora.getTime())) continue
|
||||
|
||||
// Usar índice como código único para evitar duplicatas
|
||||
const codigo = parseInt(ano) * 10000 + parseInt(mes) * 100 + parseInt(dia) + i
|
||||
|
||||
try {
|
||||
// Verificar se já existe
|
||||
const existe = await prisma.andamento.findFirst({
|
||||
where: {
|
||||
processoId: id,
|
||||
dataHora,
|
||||
nome: mov.descricao.substring(0, 200),
|
||||
},
|
||||
})
|
||||
|
||||
if (!existe) {
|
||||
await prisma.andamento.create({
|
||||
data: {
|
||||
processoId: id,
|
||||
dataHora,
|
||||
codigo,
|
||||
nome: mov.descricao.substring(0, 200),
|
||||
complemento: mov.descricao.length > 200 ? mov.descricao : null,
|
||||
},
|
||||
})
|
||||
novosAndamentos++
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorar duplicatas (unique constraint)
|
||||
console.log('[Atualizar] Andamento duplicado, ignorando')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar andamentos atualizados
|
||||
const andamentos = await prisma.andamento.findMany({
|
||||
where: { processoId: id },
|
||||
orderBy: { dataHora: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processo: updated,
|
||||
andamentos,
|
||||
novosAndamentos,
|
||||
fonte: 'tribunal-api',
|
||||
tribunal: resultado.tribunal,
|
||||
ultimaAtualizacao: resultado.ultimaAtualizacao,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Atualizar] Erro:', error)
|
||||
return NextResponse.json({
|
||||
error: 'Erro ao atualizar processo',
|
||||
detalhes: error instanceof Error ? error.message : 'Erro desconhecido'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
96
src/app/api/processos/[id]/route.ts
Normal file
96
src/app/api/processos/[id]/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const processo = await prisma.processoMonitorado.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: session.user.id,
|
||||
},
|
||||
include: {
|
||||
publicacoes: {
|
||||
orderBy: { dataPublicacao: 'desc' },
|
||||
},
|
||||
andamentos: {
|
||||
orderBy: { dataHora: 'desc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!processo) {
|
||||
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ processo })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const { status, vara, comarca, parteAutora, parteRe } = body
|
||||
|
||||
const existing = await prisma.processoMonitorado.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
const processo = await prisma.processoMonitorado.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(status && { status }),
|
||||
...(vara !== undefined && { vara }),
|
||||
...(comarca !== undefined && { comarca }),
|
||||
...(parteAutora !== undefined && { parteAutora }),
|
||||
...(parteRe !== undefined && { parteRe }),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ processo })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const existing = await prisma.processoMonitorado.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Processo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
await prisma.processoMonitorado.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
167
src/app/api/processos/route.ts
Normal file
167
src/app/api/processos/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { validarNumeroCNJ } from '@/lib/publicacoes-service'
|
||||
import { buscarProcessoCompleto } from '@/lib/diarios-service'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const status = searchParams.get('status')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = { userId: session.user.id }
|
||||
if (status) where.status = status
|
||||
|
||||
const processos = await prisma.processoMonitorado.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
publicacoes: {
|
||||
where: { visualizado: false }
|
||||
}
|
||||
}
|
||||
},
|
||||
publicacoes: {
|
||||
orderBy: { dataPublicacao: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
andamentos: {
|
||||
orderBy: { dataHora: 'desc' },
|
||||
take: 1,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Formata resposta com contagem de não lidas
|
||||
const result = processos.map(p => ({
|
||||
...p,
|
||||
publicacoesNaoLidas: p._count.publicacoes,
|
||||
ultimaPublicacao: p.publicacoes[0] || null,
|
||||
ultimoAndamento: p.andamentos[0] || null,
|
||||
publicacoes: undefined,
|
||||
andamentos: undefined,
|
||||
_count: undefined,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ processos: result })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { numeroProcesso, tribunal, vara, comarca, parteAutora, parteRe } = body
|
||||
|
||||
// Validações
|
||||
if (!numeroProcesso || !tribunal) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Número do processo e tribunal são obrigatórios' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!validarNumeroCNJ(numeroProcesso)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Número do processo inválido. Use o formato CNJ: 0000000-00.0000.0.00.0000' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verifica se já existe
|
||||
const existing = await prisma.processoMonitorado.findFirst({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
numeroProcesso,
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Este processo já está sendo monitorado' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Criar processo primeiro
|
||||
const processo = await prisma.processoMonitorado.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
numeroProcesso,
|
||||
tribunal,
|
||||
vara: vara || null,
|
||||
comarca: comarca || null,
|
||||
parteAutora: parteAutora || null,
|
||||
parteRe: parteRe || null,
|
||||
status: 'ATIVO',
|
||||
},
|
||||
})
|
||||
|
||||
// Buscar dados completos na API DataJud (não bloqueia a criação)
|
||||
try {
|
||||
const resultado = await buscarProcessoCompleto(numeroProcesso, tribunal)
|
||||
|
||||
if (resultado.sucesso && resultado.dados) {
|
||||
const dados = resultado.dados
|
||||
|
||||
// Atualizar processo com dados da API
|
||||
await prisma.processoMonitorado.update({
|
||||
where: { id: processo.id },
|
||||
data: {
|
||||
classe: dados.classe?.nome || null,
|
||||
assunto: dados.assuntos?.[0]?.nome || null,
|
||||
dataAjuizamento: dados.dataAjuizamento || null,
|
||||
orgaoJulgador: dados.orgaoJulgador?.nome || null,
|
||||
grau: dados.grau || null,
|
||||
ultimaAtualizacao: new Date(),
|
||||
dadosCompletos: dados.dadosBrutos || null,
|
||||
},
|
||||
})
|
||||
|
||||
// Salvar andamentos
|
||||
if (dados.movimentos && dados.movimentos.length > 0) {
|
||||
const andamentosData = dados.movimentos.map(mov => ({
|
||||
processoId: processo.id,
|
||||
codigo: mov.codigo,
|
||||
nome: mov.nome,
|
||||
dataHora: mov.dataHora,
|
||||
complemento: mov.complementosTabelados?.map(c => c.nome).join(', ') || null,
|
||||
}))
|
||||
|
||||
// Usar createMany com skipDuplicates para evitar erros
|
||||
await prisma.andamento.createMany({
|
||||
data: andamentosData,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[Processos] Dados completos salvos para ${numeroProcesso}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Processos] Erro ao buscar dados completos para ${numeroProcesso}:`, error)
|
||||
// Não falha a criação se a busca de dados falhar
|
||||
}
|
||||
|
||||
// Retornar processo atualizado
|
||||
const processoAtualizado = await prisma.processoMonitorado.findUnique({
|
||||
where: { id: processo.id },
|
||||
include: {
|
||||
andamentos: {
|
||||
orderBy: { dataHora: 'desc' },
|
||||
take: 5,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ processo: processoAtualizado }, { status: 201 })
|
||||
}
|
||||
41
src/app/api/publicacoes/[id]/visualizar/route.ts
Normal file
41
src/app/api/publicacoes/[id]/visualizar/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verifica se a publicação pertence a um processo do usuário
|
||||
const publicacao = await prisma.publicacao.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
processo: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!publicacao) {
|
||||
return NextResponse.json({ error: 'Publicação não encontrada' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (publicacao.processo.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 403 })
|
||||
}
|
||||
|
||||
const updated = await prisma.publicacao.update({
|
||||
where: { id },
|
||||
data: { visualizado: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({ publicacao: updated })
|
||||
}
|
||||
91
src/app/api/publicacoes/buscar/route.ts
Normal file
91
src/app/api/publicacoes/buscar/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buscarPublicacoesReais, PublicacaoEncontrada } from '@/lib/diarios-service'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { processoId, usarMock = false } = body
|
||||
|
||||
// Se processoId fornecido, busca só esse, senão busca todos do usuário
|
||||
const processos = processoId
|
||||
? await prisma.processoMonitorado.findMany({
|
||||
where: { id: processoId, userId: session.user.id, status: 'ATIVO' },
|
||||
})
|
||||
: await prisma.processoMonitorado.findMany({
|
||||
where: { userId: session.user.id, status: 'ATIVO' },
|
||||
})
|
||||
|
||||
if (processos.length === 0) {
|
||||
return NextResponse.json({ message: 'Nenhum processo ativo para buscar', found: 0 })
|
||||
}
|
||||
|
||||
let totalFound = 0
|
||||
const erros: string[] = []
|
||||
|
||||
for (const processo of processos) {
|
||||
try {
|
||||
// Busca REAL nos diários oficiais (DataJud)
|
||||
const resultado = await buscarPublicacoesReais({
|
||||
id: processo.id,
|
||||
numeroProcesso: processo.numeroProcesso,
|
||||
tribunal: processo.tribunal,
|
||||
})
|
||||
|
||||
if (!resultado.sucesso) {
|
||||
erros.push(`${processo.numeroProcesso}: ${resultado.erro}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Insere as publicações encontradas (evitando duplicatas)
|
||||
for (const pub of resultado.publicacoes) {
|
||||
// Verifica se já existe publicação com mesma data, tipo e fonte
|
||||
const existing = await prisma.publicacao.findFirst({
|
||||
where: {
|
||||
processoId: processo.id,
|
||||
dataPublicacao: {
|
||||
gte: new Date(pub.dataPublicacao.toDateString()),
|
||||
lt: new Date(new Date(pub.dataPublicacao).setDate(pub.dataPublicacao.getDate() + 1)),
|
||||
},
|
||||
tipo: pub.tipo,
|
||||
},
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.publicacao.create({
|
||||
data: {
|
||||
processoId: processo.id,
|
||||
dataPublicacao: pub.dataPublicacao,
|
||||
diario: pub.diario,
|
||||
conteudo: pub.conteudo,
|
||||
tipo: pub.tipo,
|
||||
prazoCalculado: pub.prazoCalculado,
|
||||
prazoTipo: pub.prazoTipo,
|
||||
visualizado: false,
|
||||
},
|
||||
})
|
||||
totalFound++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Erro desconhecido'
|
||||
erros.push(`${processo.numeroProcesso}: ${msg}`)
|
||||
console.error(`[API] Erro ao buscar ${processo.numeroProcesso}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: totalFound > 0
|
||||
? `Encontradas ${totalFound} nova(s) publicação(ões)`
|
||||
: 'Nenhuma nova publicação encontrada',
|
||||
found: totalFound,
|
||||
processados: processos.length,
|
||||
erros: erros.length > 0 ? erros : undefined,
|
||||
})
|
||||
}
|
||||
73
src/app/api/publicacoes/route.ts
Normal file
73
src/app/api/publicacoes/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const processoId = searchParams.get('processoId')
|
||||
const visualizado = searchParams.get('visualizado')
|
||||
const tipo = searchParams.get('tipo')
|
||||
const dataInicio = searchParams.get('dataInicio')
|
||||
const dataFim = searchParams.get('dataFim')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
// Busca IDs dos processos do usuário
|
||||
const processosDoUsuario = await prisma.processoMonitorado.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const processoIds = processosDoUsuario.map(p => p.id)
|
||||
|
||||
if (processoIds.length === 0) {
|
||||
return NextResponse.json({ publicacoes: [], total: 0 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {
|
||||
processoId: processoId ? processoId : { in: processoIds },
|
||||
}
|
||||
|
||||
if (visualizado !== null && visualizado !== undefined) {
|
||||
where.visualizado = visualizado === 'true'
|
||||
}
|
||||
|
||||
if (tipo) {
|
||||
where.tipo = tipo
|
||||
}
|
||||
|
||||
if (dataInicio || dataFim) {
|
||||
where.dataPublicacao = {}
|
||||
if (dataInicio) where.dataPublicacao.gte = new Date(dataInicio)
|
||||
if (dataFim) where.dataPublicacao.lte = new Date(dataFim)
|
||||
}
|
||||
|
||||
const [publicacoes, total] = await Promise.all([
|
||||
prisma.publicacao.findMany({
|
||||
where,
|
||||
orderBy: { dataPublicacao: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
processo: {
|
||||
select: {
|
||||
numeroProcesso: true,
|
||||
tribunal: true,
|
||||
parteAutora: true,
|
||||
parteRe: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.publicacao.count({ where }),
|
||||
])
|
||||
|
||||
return NextResponse.json({ publicacoes, total })
|
||||
}
|
||||
99
src/app/api/register/route.ts
Normal file
99
src/app/api/register/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sanitizeString, MAX_LENGTHS } from '@/lib/validate'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const name = sanitizeString(body.name, MAX_LENGTHS.name)
|
||||
const email = sanitizeString(body.email, MAX_LENGTHS.email)
|
||||
const password = typeof body.password === 'string' ? body.password.slice(0, MAX_LENGTHS.password) : ''
|
||||
const oabNumber = body.oabNumber ? sanitizeString(body.oabNumber, MAX_LENGTHS.oabNumber) : null
|
||||
const oabState = body.oabState ? sanitizeString(body.oabState, MAX_LENGTHS.oabState) : null
|
||||
const phone = body.phone ? sanitizeString(body.phone, MAX_LENGTHS.phone) : null
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nome, email e senha são obrigatórios' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email inválido' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Senha deve ter pelo menos 8 caracteres' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate OAB number format if provided
|
||||
if (oabNumber) {
|
||||
const cleanOab = oabNumber.replace(/\D/g, '')
|
||||
if (!/^\d{3,7}$/.test(cleanOab)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Número OAB inválido (3-7 dígitos)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Este email já está cadastrado' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12)
|
||||
|
||||
// Create user with FREE plan and 5 credits
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
role: 'FREE',
|
||||
plan: 'FREE',
|
||||
credits: 5,
|
||||
oabNumber,
|
||||
oabState,
|
||||
phone,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
plan: true,
|
||||
credits: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Conta criada com sucesso', user },
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Register error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro interno do servidor' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
88
src/app/api/stripe/checkout/route.ts
Normal file
88
src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { priceId } = await req.json()
|
||||
if (!priceId) {
|
||||
return NextResponse.json({ error: 'Price ID é obrigatório' }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Usuário não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get or create Stripe customer
|
||||
let customerId = user.stripeCustomerId
|
||||
|
||||
if (customerId) {
|
||||
// Verify the stored customer still exists in Stripe
|
||||
try {
|
||||
await stripe.customers.retrieve(customerId)
|
||||
} catch (err: any) {
|
||||
// Customer doesn't exist in Stripe (e.g., from test mode or deleted)
|
||||
console.warn(`Stripe customer ${customerId} not found, creating new one for user ${user.id}`)
|
||||
customerId = null
|
||||
|
||||
// Clear stale Stripe data in the database
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
metadata: { userId: user.id },
|
||||
})
|
||||
customerId = customer.id
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { stripeCustomerId: customerId },
|
||||
})
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
const checkoutSession = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `https://lexmind.adv.br/adv/dashboard?upgrade=success`,
|
||||
cancel_url: `https://lexmind.adv.br/adv/pricing`,
|
||||
allow_promotion_codes: true,
|
||||
metadata: { userId: user.id },
|
||||
subscription_data: {
|
||||
metadata: { userId: user.id },
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: checkoutSession.url })
|
||||
} catch (error: any) {
|
||||
console.error('Stripe checkout error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao criar sessão de checkout' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
src/app/api/stripe/portal/route.ts
Normal file
39
src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Não autenticado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { stripeCustomerId: true },
|
||||
})
|
||||
|
||||
if (!user?.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Nenhuma assinatura encontrada' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `https://lexmind.adv.br/adv/dashboard/configuracoes`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: portalSession.url })
|
||||
} catch (error: any) {
|
||||
console.error('Stripe portal error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Erro ao acessar portal de assinatura' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
179
src/app/api/stripe/webhook/route.ts
Normal file
179
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { stripe, PRICE_TO_PLAN } from '@/lib/stripe'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text()
|
||||
const headersList = await headers()
|
||||
const sig = headersList.get('stripe-signature')
|
||||
|
||||
if (!sig) {
|
||||
return NextResponse.json({ error: 'No signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
let event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
)
|
||||
} catch (err: any) {
|
||||
console.error('Webhook signature verification failed:', err.message)
|
||||
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as any
|
||||
const userId = session.metadata?.userId
|
||||
const subscriptionId = session.subscription as string
|
||||
const customerId = session.customer as string
|
||||
|
||||
if (!userId || !subscriptionId) {
|
||||
console.error('Missing userId or subscriptionId in checkout session')
|
||||
break
|
||||
}
|
||||
|
||||
// Get subscription to find the price
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const priceId = subscription.items.data[0]?.price?.id
|
||||
|
||||
if (!priceId) {
|
||||
console.error('No price ID found in subscription')
|
||||
break
|
||||
}
|
||||
|
||||
const planMapping = PRICE_TO_PLAN[priceId]
|
||||
if (!planMapping) {
|
||||
console.error('Unknown price ID:', priceId)
|
||||
break
|
||||
}
|
||||
|
||||
// Update user
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: planMapping.plan,
|
||||
credits: planMapping.credits,
|
||||
stripeCustomerId: customerId,
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
stripePriceId: priceId,
|
||||
},
|
||||
})
|
||||
|
||||
// Create subscription record
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
userId,
|
||||
plan: planMapping.plan,
|
||||
status: 'ACTIVE',
|
||||
stripeId: subscriptionId,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`✅ User ${userId} upgraded to ${planMapping.plan} with ${planMapping.credits} credits`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object as any
|
||||
const userId = subscription.metadata?.userId
|
||||
|
||||
if (!userId) {
|
||||
console.log('No userId in subscription metadata, skipping')
|
||||
break
|
||||
}
|
||||
|
||||
const priceId = subscription.items.data[0]?.price?.id
|
||||
if (priceId) {
|
||||
const planMapping = PRICE_TO_PLAN[priceId]
|
||||
if (planMapping) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: planMapping.plan,
|
||||
credits: planMapping.credits,
|
||||
stripePriceId: priceId,
|
||||
},
|
||||
})
|
||||
console.log(`📝 User ${userId} subscription updated to ${planMapping.plan}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Update subscription status
|
||||
if (subscription.status === 'active') {
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeId: subscription.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
})
|
||||
} else if (subscription.status === 'canceled' || subscription.status === 'unpaid') {
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeId: subscription.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as any
|
||||
const userId = subscription.metadata?.userId
|
||||
|
||||
if (userId) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: 'FREE',
|
||||
credits: 5,
|
||||
stripeSubscriptionId: null,
|
||||
stripePriceId: null,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.subscription.updateMany({
|
||||
where: { stripeId: subscription.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
|
||||
console.log(`❌ User ${userId} downgraded to FREE`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
const invoice = event.data.object as any
|
||||
// On renewal, refresh credits
|
||||
const subscriptionId = invoice.subscription as string
|
||||
if (subscriptionId && invoice.billing_reason === 'subscription_cycle') {
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const userId = sub.metadata?.userId
|
||||
const priceId = sub.items.data[0]?.price?.id
|
||||
|
||||
if (userId && priceId) {
|
||||
const planMapping = PRICE_TO_PLAN[priceId]
|
||||
if (planMapping) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: planMapping.credits },
|
||||
})
|
||||
console.log(`💰 Renewed credits for user ${userId}: ${planMapping.credits}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event: ${event.type}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error processing webhook:', err)
|
||||
// Still return 200 to prevent Stripe retries for processing errors
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
}
|
||||
113
src/app/api/templates/route.ts
Normal file
113
src/app/api/templates/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const VALID_TYPES = [
|
||||
'PETICAO_INICIAL', 'CONTESTACAO', 'APELACAO', 'RECURSO',
|
||||
'CONTRATO', 'PARECER', 'IMPUGNACAO', 'HABEAS_CORPUS',
|
||||
'MANDADO_SEGURANCA', 'OUTROS',
|
||||
]
|
||||
|
||||
const VALID_AREAS = [
|
||||
'CIVIL', 'TRABALHISTA', 'PENAL', 'TRIBUTARIO',
|
||||
'FAMILIA', 'EMPRESARIAL', 'CONSUMIDOR', 'ADMINISTRATIVO',
|
||||
]
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const type = searchParams.get('type')
|
||||
const area = searchParams.get('area')
|
||||
const search = searchParams.get('search')
|
||||
|
||||
// Return public templates + user's own templates
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const where: any = {
|
||||
OR: [
|
||||
{ isPublic: true },
|
||||
{ userId: session.user.id },
|
||||
],
|
||||
}
|
||||
|
||||
if (type) where.type = type
|
||||
if (area) where.area = area
|
||||
if (search) {
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ description: { contains: search } },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const templates = await prisma.template.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ isPublic: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
area: true,
|
||||
prompt: true,
|
||||
isPublic: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ templates })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, description, type, area, prompt } = body
|
||||
|
||||
if (!name || !description || !type || !area || !prompt) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Todos os campos são obrigatórios: name, description, type, area, prompt' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!VALID_TYPES.includes(type)) {
|
||||
return NextResponse.json({ error: `Tipo inválido. Tipos válidos: ${VALID_TYPES.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!VALID_AREAS.includes(area)) {
|
||||
return NextResponse.json({ error: `Área inválida. Áreas válidas: ${VALID_AREAS.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
area,
|
||||
prompt,
|
||||
isPublic: false,
|
||||
userId: session.user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ template }, { status: 201 })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Erro ao criar modelo' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
59
src/app/api/uploads/[id]/route.ts
Normal file
59
src/app/api/uploads/[id]/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { deleteFile, getSignedUrl } from '@/lib/spaces'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const upload = await prisma.upload.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!upload) {
|
||||
return NextResponse.json({ error: 'Arquivo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
const url = await getSignedUrl(upload.key)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const upload = await prisma.upload.findFirst({
|
||||
where: { id, userId: session.user.id },
|
||||
})
|
||||
|
||||
if (!upload) {
|
||||
return NextResponse.json({ error: 'Arquivo não encontrado' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteFile(upload.key)
|
||||
} catch (error) {
|
||||
console.error('S3 delete error:', error)
|
||||
// Continue with DB deletion even if S3 fails
|
||||
}
|
||||
|
||||
await prisma.upload.delete({ where: { id } })
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
162
src/app/api/uploads/route.ts
Normal file
162
src/app/api/uploads/route.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { uploadFile, buildKey } from '@/lib/spaces'
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
]
|
||||
|
||||
const MAX_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
|
||||
const STORAGE_LIMITS: Record<string, number> = {
|
||||
FREE: 1 * 1024 * 1024 * 1024, // 1GB
|
||||
STARTER: 1 * 1024 * 1024 * 1024, // 1GB
|
||||
PRO: 5 * 1024 * 1024 * 1024, // 5GB
|
||||
ENTERPRISE: 20 * 1024 * 1024 * 1024, // 20GB
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Nenhum arquivo enviado' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file extension (defense in depth)
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
const ALLOWED_EXTENSIONS = ["pdf", "doc", "docx", "txt"]
|
||||
if (!ext || !ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Extensão de arquivo não permitida" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate mime type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tipo de arquivo não permitido. Aceitos: PDF, DOCX, DOC, TXT' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Arquivo muito grande. Máximo: 50MB' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check storage limit
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { plan: true },
|
||||
})
|
||||
|
||||
const storageLimit = STORAGE_LIMITS[user?.plan || 'FREE'] || STORAGE_LIMITS.FREE
|
||||
|
||||
const usedStorage = await prisma.upload.aggregate({
|
||||
where: { userId: session.user.id },
|
||||
_sum: { size: true },
|
||||
})
|
||||
|
||||
const currentUsage = usedStorage._sum.size || 0
|
||||
if (currentUsage + file.size > storageLimit) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Limite de armazenamento atingido. Faça upgrade do plano para mais espaço.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Upload to Spaces
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const key = buildKey(session.user.id, file.name)
|
||||
await uploadFile(buffer, key, file.type)
|
||||
|
||||
// Save to DB
|
||||
const upload = await prisma.upload.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
filename: file.name,
|
||||
key,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
upload: {
|
||||
id: upload.id,
|
||||
filename: upload.filename,
|
||||
size: upload.size,
|
||||
mimeType: upload.mimeType,
|
||||
createdAt: upload.createdAt,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
return NextResponse.json({ error: 'Erro ao fazer upload' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Não autorizado' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const page = Math.max(1, Math.min(1000, parseInt(searchParams.get('page') || '1')))
|
||||
const limit = Math.max(1, Math.min(100, parseInt(searchParams.get('limit') || '20')))
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [uploads, total, storageUsed] = await Promise.all([
|
||||
prisma.upload.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
size: true,
|
||||
mimeType: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.upload.count({ where: { userId: session.user.id } }),
|
||||
prisma.upload.aggregate({
|
||||
where: { userId: session.user.id },
|
||||
_sum: { size: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { plan: true },
|
||||
})
|
||||
|
||||
const storageLimit = STORAGE_LIMITS[user?.plan || 'FREE'] || STORAGE_LIMITS.FREE
|
||||
|
||||
return NextResponse.json({
|
||||
uploads,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
storageUsed: storageUsed._sum.size || 0,
|
||||
storageLimit,
|
||||
})
|
||||
}
|
||||
239
src/app/dashboard/DashboardShell.tsx
Normal file
239
src/app/dashboard/DashboardShell.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FilePlus,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Brain,
|
||||
Clock,
|
||||
FileSearch,
|
||||
Scale,
|
||||
BookTemplate,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Newspaper,
|
||||
} from 'lucide-react'
|
||||
import { signOut } from 'next-auth/react'
|
||||
|
||||
interface DashboardShellProps {
|
||||
children: React.ReactNode
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
plan: string
|
||||
avatar?: string | null
|
||||
oabNumber?: string | null
|
||||
oabState?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/dashboard/nova-peca', label: 'Novo Documento', icon: FilePlus },
|
||||
{ href: '/dashboard/minhas-pecas', label: 'Meus Documentos', icon: FileText },
|
||||
{ href: '/dashboard/chat', label: 'Consultor IA', icon: MessageSquare },
|
||||
{ href: '/dashboard/jurisprudencia', label: 'Jurisprudência', icon: Brain },
|
||||
{ href: '/dashboard/prazos', label: 'Gestão de Prazos', icon: Clock },
|
||||
{ href: '/dashboard/publicacoes', label: 'Publicações', icon: Newspaper },
|
||||
{ href: '/dashboard/auditoria', label: 'Auditoria Contratos', icon: FileSearch },
|
||||
{ href: '/dashboard/analise-processo', label: 'Análise de Processo', icon: Scale },
|
||||
{ href: '/dashboard/modelos', label: 'Modelos', icon: BookTemplate },
|
||||
{ href: '/dashboard/configuracoes', label: 'Configurações', icon: Settings },
|
||||
]
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
FREE: 'bg-zinc-700 text-zinc-300',
|
||||
PRO: 'bg-teal-600/20 text-teal-400 border border-teal-500/30',
|
||||
ENTERPRISE: 'bg-amber-600/20 text-amber-400 border border-amber-500/30',
|
||||
}
|
||||
|
||||
const planLabels: Record<string, string> = {
|
||||
FREE: 'Free',
|
||||
PRO: 'Pro',
|
||||
ENTERPRISE: 'Enterprise',
|
||||
}
|
||||
|
||||
export default function DashboardShell({ children, user }: DashboardShellProps) {
|
||||
const pathname = usePathname()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') return pathname === '/dashboard'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const initials = user.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[#0f1620]">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-white/5 bg-[#0a1018]
|
||||
transition-transform duration-300 ease-in-out lg:static lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-white/5 px-6">
|
||||
<div className="relative flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-teal-600 via-teal-500 to-cyan-500">
|
||||
<Brain className="h-5 w-5 text-white" />
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-gradient-to-br from-cyan-400 to-teal-600 flex items-center justify-center">
|
||||
<span className="text-[6px] font-bold text-white">§</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-bold text-white">Lex</span>
|
||||
<span className="text-lg font-bold text-teal-400">Mind</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="ml-auto rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white lg:hidden"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`
|
||||
group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium
|
||||
transition-all duration-150
|
||||
${
|
||||
active
|
||||
? 'bg-teal-600/15 text-teal-400 shadow-[inset_0_0_0_1px_rgba(139,92,246,0.2)]'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-5 w-5 flex-shrink-0 ${
|
||||
active ? 'text-teal-400' : 'text-zinc-500 group-hover:text-zinc-300'
|
||||
}`}
|
||||
/>
|
||||
{item.label}
|
||||
{active && (
|
||||
<ChevronRight className="ml-auto h-4 w-4 text-teal-500/60" />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Upgrade CTA for free users */}
|
||||
{user.plan === 'FREE' && (
|
||||
<div className="mx-1 mt-6 rounded-xl border border-teal-500/20 bg-gradient-to-br from-teal-600/10 to-teal-900/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-teal-300">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Ativar plano Advocacia
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs leading-relaxed text-zinc-400">
|
||||
Documentos ilimitados, consultor avançado e jurisprudência premium.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/configuracoes#planos"
|
||||
className="mt-3 block rounded-lg bg-teal-600 px-3 py-1.5 text-center text-xs font-semibold text-white transition-colors hover:bg-teal-500"
|
||||
>
|
||||
Ver opções
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div className="border-t border-white/5 p-3">
|
||||
<div className="flex items-center gap-3 rounded-xl px-3 py-3">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="h-9 w-9 rounded-full object-cover ring-2 ring-teal-500/30"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-teal-600/20 text-sm font-bold text-teal-400 ring-2 ring-teal-500/30">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium text-white">{user.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-xs text-zinc-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
planColors[user.plan] || planColors.FREE
|
||||
}`}
|
||||
>
|
||||
{planLabels[user.plan] || user.plan}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-zinc-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Top bar (mobile) */}
|
||||
<header className="flex h-14 items-center gap-3 border-b border-white/5 bg-[#0a1018]/80 px-4 backdrop-blur-md lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="rounded-lg p-2 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-teal-500 via-teal-500 to-cyan-500">
|
||||
<Brain className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-white">Lex</span>
|
||||
<span className="text-sm font-bold text-teal-400">Mind</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/app/dashboard/UpgradeBanner.tsx
Normal file
78
src/app/dashboard/UpgradeBanner.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle2, X, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export function UpgradeSuccessBanner() {
|
||||
const searchParams = useSearchParams()
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('upgrade') === 'success') {
|
||||
setShow(true)
|
||||
// Clean URL
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-emerald-500/20 bg-emerald-950/20 p-4 sm:p-6 mb-6 animate-fade-in-up">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-emerald-600/10 blur-3xl" />
|
||||
<div className="relative flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-emerald-500/20 p-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-emerald-300">Assinatura ativada com sucesso! 🎉</h3>
|
||||
<p className="text-xs text-emerald-400/70">
|
||||
Seus créditos foram atualizados. Aproveite todos os recursos do seu novo plano.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShow(false)}
|
||||
className="rounded-lg p-1 text-emerald-400/50 hover:text-emerald-300 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpgradePrompt({ plan, credits }: { plan: string; credits: number }) {
|
||||
if (plan !== 'FREE') return null
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-teal-500/20 bg-gradient-to-r from-teal-950/30 via-[#111b27] to-teal-950/30 p-4 sm:p-6 mb-6">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-teal-600/10 blur-3xl" />
|
||||
<div className="relative flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-teal-500/20 p-2">
|
||||
<Sparkles className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Você está no plano Gratuito ({credits} créditos restantes)
|
||||
</h3>
|
||||
<p className="text-xs text-zinc-400">
|
||||
Faça upgrade para ter mais créditos, IA avançada e funcionalidades premium.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="shrink-0 inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold bg-teal-600 text-white hover:bg-teal-500 transition-all"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Ver Planos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
500
src/app/dashboard/analise-processo/page.tsx
Normal file
500
src/app/dashboard/analise-processo/page.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import {
|
||||
FileText,
|
||||
Upload,
|
||||
Brain,
|
||||
Scale,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
FileDown,
|
||||
Plus,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
X,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface AnalysisSummary {
|
||||
id: string
|
||||
title: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
status: string
|
||||
summary: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function AnaliseProcessoPage() {
|
||||
const router = useRouter()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analysis, setAnalysis] = useState('')
|
||||
const [analysisId, setAnalysisId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [history, setHistory] = useState<AnalysisSummary[]>([])
|
||||
const [loadingHistory, setLoadingHistory] = useState(true)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const analysisRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analise-processo')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setHistory(data.analyses || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingHistory(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory()
|
||||
}, [fetchHistory])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const dropped = e.dataTransfer.files[0]
|
||||
if (dropped?.type === 'application/pdf') {
|
||||
setFile(dropped)
|
||||
setError(null)
|
||||
} else {
|
||||
setError('Apenas arquivos PDF são aceitos.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0]
|
||||
if (selected) {
|
||||
if (selected.type === 'application/pdf') {
|
||||
setFile(selected)
|
||||
setError(null)
|
||||
} else {
|
||||
setError('Apenas arquivos PDF são aceitos.')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!file) return
|
||||
setAnalyzing(true)
|
||||
setAnalysis('')
|
||||
setAnalysisId(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch('/api/analise-processo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error || 'Erro ao processar análise')
|
||||
setAnalyzing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) {
|
||||
setError('Erro na conexão com o servidor')
|
||||
setAnalyzing(false)
|
||||
return
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.type === 'id') {
|
||||
setAnalysisId(data.id)
|
||||
} else if (data.type === 'chunk') {
|
||||
setAnalysis(prev => prev + data.content)
|
||||
} else if (data.type === 'done') {
|
||||
setAnalysisId(data.id)
|
||||
fetchHistory()
|
||||
} else if (data.type === 'error') {
|
||||
setError(data.message)
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Erro de conexão. Tente novamente.')
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const plain = analysis.replace(/#{1,6}\s/g, '').replace(/\*\*/g, '').replace(/\*/g, '').replace(/- /g, '• ')
|
||||
await navigator.clipboard.writeText(plain)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleExportDocx = async () => {
|
||||
const title = file?.name?.replace(/\.pdf$/i, '') || 'Análise de Processo'
|
||||
const res = await fetch('/api/export/docx', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: `Análise - ${title}`, content: analysis }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `analise-${title}.docx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewHistory = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/analise-processo/${id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAnalysis(data.analysis.analysis)
|
||||
setAnalysisId(data.analysis.id)
|
||||
setFile(null)
|
||||
setShowHistory(false)
|
||||
}
|
||||
} catch {
|
||||
setError('Erro ao carregar análise')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null)
|
||||
setAnalysis('')
|
||||
setAnalysisId(null)
|
||||
setError(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DONE: 'text-emerald-400',
|
||||
ANALYZING: 'text-amber-400',
|
||||
PENDING: 'text-zinc-400',
|
||||
ERROR: 'text-red-400',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
DONE: 'Concluída',
|
||||
ANALYZING: 'Analisando...',
|
||||
PENDING: 'Pendente',
|
||||
ERROR: 'Erro',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
|
||||
<Scale className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
Análise de Processo
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Envie um PDF e receba uma análise jurídica completa com IA
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Histórico
|
||||
{history.length > 0 && (
|
||||
<span className="rounded-full bg-teal-600/30 px-2 py-0.5 text-xs text-teal-400">
|
||||
{history.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Upload / Result */}
|
||||
{!analysis ? (
|
||||
<div className="rounded-2xl border border-white/5 bg-[#0d1420] p-6">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative cursor-pointer rounded-xl border-2 border-dashed p-12 text-center transition-all duration-200 ${
|
||||
dragOver
|
||||
? 'border-teal-400 bg-teal-400/5'
|
||||
: file
|
||||
? 'border-teal-500/30 bg-teal-500/5'
|
||||
: 'border-white/10 hover:border-white/20 hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{file ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/20">
|
||||
<FileText className="h-8 w-8 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-white">{file.name}</p>
|
||||
<p className="text-sm text-zinc-400">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleReset()
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-3 w-3" /> Remover
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5">
|
||||
<Upload className="h-8 w-8 text-zinc-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium text-zinc-300">
|
||||
Arraste um PDF aqui ou clique para selecionar
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Processos judiciais, petições, contratos e outros documentos jurídicos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analyze Button */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<Sparkles className="h-3 w-3 text-teal-500" />
|
||||
A análise consome 5 créditos
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!file || analyzing}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Analisando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Brain className="h-4 w-4" />
|
||||
Analisar Processo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Action Bar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Análise
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportDocx}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
Baixar Word
|
||||
</button>
|
||||
{analysisId && !analyzing && (
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/nova-peca?from_analysis=${analysisId}`)}
|
||||
className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-teal-600 to-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-teal-500/20 transition hover:from-teal-500 hover:to-emerald-500"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Gerar Petição baseada neste Parecer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Analysis Content */}
|
||||
<div
|
||||
ref={analysisRef}
|
||||
className="rounded-2xl border border-white/5 bg-[#0d1420] p-6 md:p-8"
|
||||
>
|
||||
{analyzing && (
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-teal-400">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Analisando documento...
|
||||
</div>
|
||||
)}
|
||||
<div className="prose prose-invert prose-teal max-w-none prose-headings:text-white prose-headings:border-b prose-headings:border-white/5 prose-headings:pb-2 prose-headings:mb-4 prose-h2:text-lg prose-h2:mt-8 prose-p:text-zinc-300 prose-li:text-zinc-300 prose-strong:text-teal-300 prose-ul:space-y-1">
|
||||
<ReactMarkdown>{analysis}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* History Sidebar */}
|
||||
{showHistory && (
|
||||
<div className="hidden w-80 shrink-0 lg:block">
|
||||
<div className="sticky top-6 rounded-2xl border border-white/5 bg-[#0d1420] p-4">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Clock className="h-4 w-4 text-teal-400" />
|
||||
Análises Anteriores
|
||||
</h3>
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-zinc-500">
|
||||
Nenhuma análise ainda
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleViewHistory(item.id)}
|
||||
className={`w-full rounded-xl p-3 text-left transition hover:bg-white/5 ${
|
||||
analysisId === item.id ? 'bg-teal-600/10 border border-teal-500/20' : 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{formatDate(item.createdAt)} • {formatFileSize(item.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs ${statusColors[item.status] || 'text-zinc-400'}`}>
|
||||
{statusLabels[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile History */}
|
||||
{showHistory && (
|
||||
<div className="lg:hidden rounded-2xl border border-white/5 bg-[#0d1420] p-4">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Clock className="h-4 w-4 text-teal-400" />
|
||||
Análises Anteriores
|
||||
</h3>
|
||||
{loadingHistory ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-zinc-500">Nenhuma análise ainda</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleViewHistory(item.id)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-white/5 p-3 text-left transition hover:bg-white/5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{item.title}</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{formatDate(item.createdAt)} • {formatFileSize(item.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-zinc-500" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
483
src/app/dashboard/auditoria/page.tsx
Normal file
483
src/app/dashboard/auditoria/page.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
'use client'
|
||||
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
FileSearch,
|
||||
Plus,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
Shield,
|
||||
AlertOctagon,
|
||||
Search,
|
||||
Lightbulb,
|
||||
FileWarning,
|
||||
ThumbsUp,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface AuditSummary {
|
||||
id: string
|
||||
title: string
|
||||
status: 'PENDING' | 'ANALYZING' | 'DONE' | 'ERROR'
|
||||
riskScore: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface AuditDetail {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
riskScore: number | null
|
||||
analysis: {
|
||||
resumo?: string
|
||||
riskScore?: number
|
||||
clausulasAbusivas?: { clausula: string; descricao: string; gravidade: string }[]
|
||||
inconsistencias?: { item: string; descricao: string }[]
|
||||
lacunas?: { item: string; descricao: string }[]
|
||||
riscos?: { risco: string; descricao: string; gravidade: string }[]
|
||||
sugestoes?: { sugestao: string; descricao: string }[]
|
||||
pontosFavoraveis?: { ponto: string; descricao: string }[]
|
||||
} | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string; icon: typeof Clock }> = {
|
||||
PENDING: { label: 'Pendente', color: 'text-zinc-400', icon: Clock },
|
||||
ANALYZING: { label: 'Analisando', color: 'text-yellow-400', icon: Loader2 },
|
||||
DONE: { label: 'Concluído', color: 'text-green-400', icon: CheckCircle2 },
|
||||
ERROR: { label: 'Erro', color: 'text-red-400', icon: XCircle },
|
||||
}
|
||||
|
||||
function RiskGauge({ score }: { score: number }) {
|
||||
const getColor = (s: number) => {
|
||||
if (s <= 30) return { stroke: 'stroke-green-500', text: 'text-green-400', label: 'Baixo Risco' }
|
||||
if (s <= 60) return { stroke: 'stroke-yellow-500', text: 'text-yellow-400', label: 'Risco Moderado' }
|
||||
if (s <= 80) return { stroke: 'stroke-orange-500', text: 'text-orange-400', label: 'Risco Alto' }
|
||||
return { stroke: 'stroke-red-500', text: 'text-red-400', label: 'Risco Crítico' }
|
||||
}
|
||||
const config = getColor(score)
|
||||
const circumference = 2 * Math.PI * 45
|
||||
const dashOffset = circumference - (score / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative h-32 w-32">
|
||||
<svg className="h-32 w-32 -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="45" fill="none" stroke="currentColor" strokeWidth="8" className="text-white/5" />
|
||||
<circle
|
||||
cx="50" cy="50" r="45" fill="none" strokeWidth="8" strokeLinecap="round"
|
||||
className={config.stroke}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-in-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-3xl font-bold ${config.text}`}>{score}</span>
|
||||
<span className="text-[10px] text-zinc-500">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-2 text-sm font-semibold ${config.text}`}>{config.label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AuditoriaPage() {
|
||||
const [audits, setAudits] = useState<AuditSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState<'list' | 'new' | 'detail'>('list')
|
||||
const [selectedAudit, setSelectedAudit] = useState<AuditDetail | null>(null)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Form
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchAudits()
|
||||
}, [])
|
||||
|
||||
async function fetchAudits() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/auditoria')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAudits(data.audits || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load audits:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!title.trim() || !content.trim()) return
|
||||
setAnalyzing(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auditoria', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Erro ao analisar')
|
||||
return
|
||||
}
|
||||
// Show result
|
||||
setSelectedAudit(data.audit)
|
||||
setView('detail')
|
||||
setTitle('')
|
||||
setContent('')
|
||||
fetchAudits()
|
||||
} catch (e) {
|
||||
setError('Erro de conexão')
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditDetail(id: string) {
|
||||
setLoadingDetail(true)
|
||||
try {
|
||||
const res = await fetch(`/api/auditoria/${id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSelectedAudit(data.audit)
|
||||
setView('detail')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load detail:', e)
|
||||
} finally {
|
||||
setLoadingDetail(false)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function getRiskColor(score: number | null) {
|
||||
if (score === null) return 'text-zinc-500'
|
||||
if (score <= 30) return 'text-green-400'
|
||||
if (score <= 60) return 'text-yellow-400'
|
||||
if (score <= 80) return 'text-orange-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
// ─── Detail View ───
|
||||
if (view === 'detail' && selectedAudit) {
|
||||
const a = selectedAudit.analysis
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => { setView('list'); setSelectedAudit(null) }}
|
||||
className="mb-6 flex items-center gap-2 text-sm text-zinc-400 hover:text-teal-400"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar à lista
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{selectedAudit.title}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Analisado em {formatDate(selectedAudit.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{a && (
|
||||
<div className="space-y-6">
|
||||
{/* Summary + Risk Score */}
|
||||
<div className="grid gap-6 md:grid-cols-[1fr,200px]">
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-6">
|
||||
<h3 className="text-sm font-semibold text-teal-400 mb-2">Resumo Executivo</h3>
|
||||
<p className="text-sm text-zinc-300 leading-relaxed">{a.resumo || 'Sem resumo disponível.'}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<RiskGauge score={selectedAudit.riskScore || 0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Findings */}
|
||||
{a.clausulasAbusivas && a.clausulasAbusivas.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Cláusulas Abusivas"
|
||||
icon={<AlertOctagon className="h-5 w-5 text-red-400" />}
|
||||
borderColor="border-red-500/20"
|
||||
items={a.clausulasAbusivas.map(c => ({
|
||||
title: c.clausula,
|
||||
desc: c.descricao,
|
||||
badge: c.gravidade,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{a.riscos && a.riscos.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Riscos Identificados"
|
||||
icon={<AlertTriangle className="h-5 w-5 text-orange-400" />}
|
||||
borderColor="border-orange-500/20"
|
||||
items={a.riscos.map(r => ({
|
||||
title: r.risco,
|
||||
desc: r.descricao,
|
||||
badge: r.gravidade,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{a.inconsistencias && a.inconsistencias.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Inconsistências"
|
||||
icon={<FileWarning className="h-5 w-5 text-yellow-400" />}
|
||||
borderColor="border-yellow-500/20"
|
||||
items={a.inconsistencias.map(i => ({ title: i.item, desc: i.descricao }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{a.lacunas && a.lacunas.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Lacunas"
|
||||
icon={<Search className="h-5 w-5 text-blue-400" />}
|
||||
borderColor="border-blue-500/20"
|
||||
items={a.lacunas.map(l => ({ title: l.item, desc: l.descricao }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{a.sugestoes && a.sugestoes.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Sugestões de Melhoria"
|
||||
icon={<Lightbulb className="h-5 w-5 text-teal-400" />}
|
||||
borderColor="border-teal-500/20"
|
||||
items={a.sugestoes.map(s => ({ title: s.sugestao, desc: s.descricao }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{a.pontosFavoraveis && a.pontosFavoraveis.length > 0 && (
|
||||
<FindingsSection
|
||||
title="Pontos Favoráveis"
|
||||
icon={<ThumbsUp className="h-5 w-5 text-green-400" />}
|
||||
borderColor="border-green-500/20"
|
||||
items={a.pontosFavoraveis.map(p => ({ title: p.ponto, desc: p.descricao }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── New Audit View ───
|
||||
if (view === 'new') {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className="mb-6 flex items-center gap-2 text-sm text-zinc-400 hover:text-teal-400"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar à lista
|
||||
</button>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
|
||||
<FileSearch className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
Nova Auditoria de Contrato
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500 flex items-center gap-2">
|
||||
<Coins className="h-4 w-4" />
|
||||
Custo: 3 créditos por análise
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Título do Contrato *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
placeholder="Ex: Contrato de Prestação de Serviços - Empresa X"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Texto do Contrato *</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-teal-500/40 font-mono leading-relaxed"
|
||||
rows={16}
|
||||
placeholder="Cole aqui o texto completo do contrato para análise..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-600">{content.length} caracteres {content.length < 100 && content.length > 0 ? '(mínimo 100)' : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<p className="text-sm font-medium text-zinc-400 mb-2">Ou faça upload do contrato</p>
|
||||
<FileUpload compact label="Upload de Contrato" maxFiles={1} />
|
||||
<p className="mt-2 text-xs text-zinc-600">O texto será extraído automaticamente do arquivo para análise.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!title.trim() || content.length < 100 || analyzing}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20 disabled:opacity-50"
|
||||
>
|
||||
{analyzing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Analisando contrato...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="h-4 w-4" />
|
||||
Analisar Contrato (3 créditos)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── List View ───
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
|
||||
<FileSearch className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
Auditoria de Contratos
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Análise inteligente de contratos com IA</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setView('new')}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Auditoria
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : audits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<FileSearch className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhuma auditoria realizada</p>
|
||||
<button onClick={() => setView('new')} className="mt-4 text-sm text-teal-400 hover:text-teal-300">
|
||||
Fazer primeira auditoria
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{audits.map(audit => {
|
||||
const st = statusLabels[audit.status] || statusLabels.PENDING
|
||||
const StIcon = st.icon
|
||||
return (
|
||||
<button
|
||||
key={audit.id}
|
||||
onClick={() => loadAuditDetail(audit.id)}
|
||||
className="group flex w-full items-center gap-4 rounded-xl border border-white/5 bg-white/[0.02] p-4 text-left transition-all hover:bg-white/[0.04] hover:border-white/10"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-teal-600/10 flex-shrink-0">
|
||||
<FileSearch className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-white truncate">{audit.title}</h3>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{formatDate(audit.createdAt)}</p>
|
||||
</div>
|
||||
{audit.riskScore !== null && (
|
||||
<div className={`text-lg font-bold ${getRiskColor(audit.riskScore)}`}>
|
||||
{audit.riskScore}
|
||||
<span className="text-xs font-normal text-zinc-600">/100</span>
|
||||
</div>
|
||||
)}
|
||||
<span className={`flex items-center gap-1 text-xs font-medium ${st.color}`}>
|
||||
<StIcon className={`h-3.5 w-3.5 ${audit.status === 'ANALYZING' ? 'animate-spin' : ''}`} />
|
||||
{st.label}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDetail && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-[#0f1620] border border-white/10 px-6 py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-teal-400" />
|
||||
<span className="text-sm text-zinc-300">Carregando auditoria...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Findings Section Component ───
|
||||
function FindingsSection({ title, icon, borderColor, items }: {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
borderColor: string
|
||||
items: { title: string; desc: string; badge?: string }[]
|
||||
}) {
|
||||
const badgeColors: Record<string, string> = {
|
||||
alta: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
media: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
||||
baixa: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${borderColor} bg-white/[0.02] p-6`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{icon}
|
||||
<h3 className="text-sm font-semibold text-white">{title}</h3>
|
||||
<span className="ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-bold text-zinc-400">
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-zinc-200">{item.title}</h4>
|
||||
{item.badge && (
|
||||
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase flex-shrink-0 ${badgeColors[item.badge] || 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30'}`}>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-400 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
569
src/app/dashboard/chat/page.tsx
Normal file
569
src/app/dashboard/chat/page.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
Plus,
|
||||
Copy,
|
||||
Check,
|
||||
FileDown,
|
||||
Loader2,
|
||||
Brain,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Clock,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { stripMarkdown, downloadAsWord } from '@/lib/utils'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
role: 'USER' | 'ASSISTANT'
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ChatSummary {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
messages: { content: string; createdAt: string }[]
|
||||
}
|
||||
|
||||
// ─── Suggested Questions ─────────────────────────────────────────
|
||||
|
||||
const SUGGESTED_QUESTIONS = [
|
||||
{
|
||||
text: 'Qual o prazo para contestação no JEC?',
|
||||
icon: '⚖️',
|
||||
area: 'Processual Civil',
|
||||
},
|
||||
{
|
||||
text: 'Como funciona o recurso de apelação?',
|
||||
icon: '📋',
|
||||
area: 'Recursos',
|
||||
},
|
||||
{
|
||||
text: 'Quais os requisitos do habeas corpus?',
|
||||
icon: '🔓',
|
||||
area: 'Penal',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────
|
||||
|
||||
export default function ChatPage() {
|
||||
const [chats, setChats] = useState<ChatSummary[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [loadingChats, setLoadingChats] = useState(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Delete chat
|
||||
async function deleteChat(chatId: string) {
|
||||
if (!confirm('Tem certeza que deseja apagar esta conversa?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/chat/${chatId}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setChats((prev) => prev.filter((c) => c.id !== chatId))
|
||||
if (activeChatId === chatId) {
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting chat:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages, scrollToBottom])
|
||||
|
||||
// Load chat list
|
||||
useEffect(() => {
|
||||
fetchChats()
|
||||
}, [])
|
||||
|
||||
async function fetchChats() {
|
||||
try {
|
||||
const res = await fetch('/api/chat')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setChats(data.chats || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load chats:', e)
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load messages for a chat
|
||||
async function loadChat(chatId: string) {
|
||||
setActiveChatId(chatId)
|
||||
try {
|
||||
const res = await fetch(`/api/chat/${chatId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setMessages(data.messages || [])
|
||||
}
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
}
|
||||
|
||||
// New chat
|
||||
function handleNewChat() {
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
setInput('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
// Send message
|
||||
async function handleSend(text?: string) {
|
||||
const messageText = (text || input).trim()
|
||||
if (!messageText || isStreaming) return
|
||||
|
||||
setInput('')
|
||||
setIsStreaming(true)
|
||||
|
||||
// Optimistic: add user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
role: 'USER',
|
||||
content: messageText,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
|
||||
// Placeholder for assistant
|
||||
const assistantId = `assistant-${Date.now()}`
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'ASSISTANT',
|
||||
content: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMsg])
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: messageText,
|
||||
chatId: activeChatId || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}))
|
||||
throw new Error(errData.error || 'Erro ao enviar mensagem')
|
||||
}
|
||||
|
||||
// Read SSE stream
|
||||
const reader = res.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let fullContent = ''
|
||||
let receivedChatId: string | null = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
|
||||
if (parsed.chatId && !receivedChatId) {
|
||||
receivedChatId = parsed.chatId
|
||||
if (!activeChatId) {
|
||||
setActiveChatId(receivedChatId)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.token) {
|
||||
fullContent += parsed.token
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId ? { ...m, content: fullContent } : m
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh chat list
|
||||
fetchChats()
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Erro desconhecido'
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantId
|
||||
? { ...m, content: `⚠️ ${errMsg}. Tente novamente.` }
|
||||
: m
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setIsStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy message - strips markdown formatting
|
||||
function handleCopy(content: string, id: string) {
|
||||
navigator.clipboard.writeText(stripMarkdown(content))
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
// Download as Word
|
||||
async function handleDownloadWord(content: string, id: string) {
|
||||
setDownloadingId(id)
|
||||
try {
|
||||
await downloadAsWord('Resposta Chat IA', content)
|
||||
} catch (err) {
|
||||
console.error('Download error:', err)
|
||||
} finally {
|
||||
setDownloadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return 'Hoje'
|
||||
if (days === 1) return 'Ontem'
|
||||
if (days < 7) return `${days} dias atrás`
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })
|
||||
}
|
||||
|
||||
// Keyboard handler
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
setInput(e.target.value)
|
||||
const el = e.target
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mx-4 -my-6 sm:-mx-6 lg:-mx-8 flex h-[calc(100vh-3.5rem)] lg:h-screen">
|
||||
{/* ─── Chat Sidebar ─── */}
|
||||
<aside
|
||||
className={`
|
||||
${sidebarOpen ? 'w-72' : 'w-0 overflow-hidden'}
|
||||
flex-shrink-0 border-r border-white/5 bg-[#070b14] transition-all duration-300
|
||||
flex flex-col
|
||||
`}
|
||||
>
|
||||
{/* New Chat Button */}
|
||||
<div className="p-3 border-b border-white/5">
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="flex w-full items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat List */}
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{loadingChats ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="px-3 py-8 text-center">
|
||||
<MessageSquare className="mx-auto h-8 w-8 text-zinc-600" />
|
||||
<p className="mt-2 text-xs text-zinc-500">Nenhuma conversa ainda</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{chats.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => loadChat(chat.id)}
|
||||
className={`
|
||||
group flex w-full items-start gap-2 rounded-lg px-3 py-2.5 text-left
|
||||
transition-all duration-150
|
||||
${
|
||||
activeChatId === chat.id
|
||||
? 'bg-teal-600/15 text-teal-300'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-200'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<MessageSquare className="mt-0.5 h-4 w-4 flex-shrink-0 opacity-50" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium">{chat.title}</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-zinc-600">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{formatDate(chat.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteChat(chat.id)
|
||||
}}
|
||||
className="mt-0.5 hidden rounded p-1 text-zinc-500 transition-colors hover:bg-red-500/20 hover:text-red-400 group-hover:block"
|
||||
title="Apagar conversa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ─── Chat Main Area ─── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden bg-[#0a0f1a]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-white/5 bg-[#0a0f1a]/80 px-4 py-3 backdrop-blur-md">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white"
|
||||
title={sidebarOpen ? 'Fechar sidebar' : 'Abrir sidebar'}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/20">
|
||||
<Brain className="h-4 w-4 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-semibold text-white">Chat IA</h1>
|
||||
<p className="text-[10px] text-zinc-500">Assistente especialista em Direito brasileiro</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
/* ─── Welcome Screen ─── */
|
||||
<div className="flex h-full flex-col items-center justify-center px-4">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/20 ring-1 ring-teal-500/30">
|
||||
<Brain className="h-8 w-8 text-teal-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white">Chat IA</h2>
|
||||
<p className="mt-2 max-w-md text-center text-sm text-zinc-500">
|
||||
Converse com um assistente especialista em Direito brasileiro.
|
||||
Tire dúvidas sobre legislação, jurisprudência e doutrina.
|
||||
</p>
|
||||
|
||||
{/* Suggested Questions */}
|
||||
<div className="mt-8 grid w-full max-w-2xl gap-3 sm:grid-cols-3">
|
||||
{SUGGESTED_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q.text}
|
||||
onClick={() => handleSend(q.text)}
|
||||
disabled={isStreaming}
|
||||
className="group flex flex-col gap-2 rounded-xl border border-white/5 bg-white/[0.02] p-4 text-left transition-all hover:border-teal-500/30 hover:bg-teal-600/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{q.icon}</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-teal-400/60">
|
||||
{q.area}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 group-hover:text-white">
|
||||
{q.text}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-2 text-[10px] text-zinc-600">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Respostas baseadas na legislação vigente e jurisprudência dos tribunais superiores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ─── Message List ─── */
|
||||
<div className="mx-auto max-w-3xl px-4 py-6 space-y-6">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${msg.role === 'USER' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{msg.role === 'ASSISTANT' && (
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/20">
|
||||
<Brain className="h-4 w-4 text-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`group relative max-w-[85%] rounded-2xl px-4 py-3 ${
|
||||
msg.role === 'USER'
|
||||
? 'bg-teal-600 text-white'
|
||||
: 'bg-white/[0.04] text-zinc-200 border border-white/5'
|
||||
}`}
|
||||
>
|
||||
{msg.role === 'ASSISTANT' ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none prose-headings:text-teal-300 prose-headings:font-semibold prose-strong:text-teal-200 prose-code:text-teal-300 prose-blockquote:border-teal-500/30 prose-blockquote:text-zinc-400 prose-a:text-teal-400 prose-li:marker:text-teal-500">
|
||||
<ReactMarkdown>{msg.content || '▍'}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons for assistant messages */}
|
||||
{msg.content && msg.role === 'ASSISTANT' && (
|
||||
<div className="absolute -bottom-8 right-0 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleCopy(msg.content, msg.id)}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-400" />
|
||||
<span className="text-green-400">Copiado</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copiar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadWord(msg.content, msg.id)}
|
||||
disabled={downloadingId === msg.id}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{downloadingId === msg.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-3 w-3" />
|
||||
)}
|
||||
Word
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy button for user messages */}
|
||||
{msg.content && msg.role === 'USER' && (
|
||||
<button
|
||||
onClick={() => handleCopy(msg.content, msg.id)}
|
||||
className="absolute -bottom-8 right-0 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] text-zinc-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-zinc-300"
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-400" />
|
||||
<span className="text-green-400">Copiado</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copiar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg.role === 'USER' && (
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-600/40 text-xs font-bold text-white">
|
||||
Vc
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 px-11 text-xs text-teal-400/60">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Gerando resposta...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Input Area ─── */}
|
||||
<div className="border-t border-white/5 bg-[#0a0f1a]/80 px-4 py-3 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-2 transition-colors focus-within:border-teal-500/40">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua pergunta jurídica..."
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-white placeholder-zinc-500 outline-none disabled:opacity-50"
|
||||
style={{ maxHeight: '160px' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-teal-600 text-white transition-all hover:bg-teal-500 disabled:opacity-30 disabled:hover:bg-teal-600"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[10px] text-zinc-600">
|
||||
IA pode gerar informações imprecisas. Verifique sempre com a legislação vigente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
850
src/app/dashboard/configuracoes/SettingsClient.tsx
Normal file
850
src/app/dashboard/configuracoes/SettingsClient.tsx
Normal file
@@ -0,0 +1,850 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Scale,
|
||||
CreditCard,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Bell,
|
||||
BellRing,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Sparkles,
|
||||
Crown,
|
||||
Zap,
|
||||
Clock,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Upload as UploadIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
interface UserData {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
oabNumber: string | null
|
||||
oabState: string | null
|
||||
avatar: string | null
|
||||
plan: string
|
||||
credits: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ApiKeyData {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface SubscriptionData {
|
||||
plan: string
|
||||
status: string
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
used: number
|
||||
limit: number
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: UserData
|
||||
apiKeys: ApiKeyData[]
|
||||
subscription: SubscriptionData | null
|
||||
storage: StorageData
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const planConfig: Record<string, { label: string; color: string; icon: typeof Sparkles }> = {
|
||||
FREE: { label: 'Free', color: 'bg-zinc-700 text-zinc-300', icon: Zap },
|
||||
STARTER: { label: 'Starter', color: 'bg-blue-600/20 text-blue-400 border border-blue-500/30', icon: Zap },
|
||||
PRO: { label: 'Pro', color: 'bg-teal-600/20 text-teal-400 border border-teal-500/30', icon: Crown },
|
||||
ENTERPRISE: { label: 'Enterprise', color: 'bg-amber-600/20 text-amber-400 border border-amber-500/30', icon: Sparkles },
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso))
|
||||
}
|
||||
|
||||
// ─── Section wrapper ────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
id,
|
||||
title,
|
||||
icon: Icon,
|
||||
description,
|
||||
children,
|
||||
danger,
|
||||
}: {
|
||||
id?: string
|
||||
title: string
|
||||
icon: typeof User
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={`overflow-hidden rounded-2xl border ${
|
||||
danger
|
||||
? 'border-red-500/20 bg-red-950/10'
|
||||
: 'border-white/5 bg-[#111b27]'
|
||||
}`}
|
||||
>
|
||||
<div className={`border-b px-6 py-4 ${danger ? 'border-red-500/10' : 'border-white/5'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${danger ? 'bg-red-500/15' : 'bg-teal-500/15'}`}>
|
||||
<Icon className={`h-4 w-4 ${danger ? 'text-red-400' : 'text-teal-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{title}</h2>
|
||||
{description && <p className="text-sm text-zinc-500">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Toggle component ───────────────────────────────────────────────
|
||||
|
||||
function Toggle({
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
enabled,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof Bell
|
||||
enabled: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-4 w-4 flex-shrink-0 text-zinc-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
||||
<p className="text-xs text-zinc-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!enabled)}
|
||||
className="flex-shrink-0 transition-colors"
|
||||
aria-label={`Toggle ${label}`}
|
||||
>
|
||||
{enabled ? (
|
||||
<ToggleRight className="h-7 w-7 text-teal-400" />
|
||||
) : (
|
||||
<ToggleLeft className="h-7 w-7 text-zinc-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ─────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsClient({ user, apiKeys: initialKeys, subscription, storage }: Props) {
|
||||
// ── API Keys state ──
|
||||
const [keys, setKeys] = useState(initialKeys)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null)
|
||||
const [copiedKey, setCopiedKey] = useState(false)
|
||||
const [keyLoading, setKeyLoading] = useState(false)
|
||||
const [keyError, setKeyError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [portalLoading, setPortalLoading] = useState(false)
|
||||
|
||||
// ── Notifications state (local only for now) ──
|
||||
const [notifications, setNotifications] = useState({
|
||||
emailAlerts: true,
|
||||
documentReady: true,
|
||||
creditsLow: true,
|
||||
weeklyReport: false,
|
||||
})
|
||||
|
||||
// ── Security state ──
|
||||
const [showCurrentPw, setShowCurrentPw] = useState(false)
|
||||
const [showNewPw, setShowNewPw] = useState(false)
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
current: '',
|
||||
newPassword: '',
|
||||
confirm: '',
|
||||
})
|
||||
|
||||
// ── Delete account state ──
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [showDeleteInput, setShowDeleteInput] = useState(false)
|
||||
|
||||
// ── API Key handlers ──
|
||||
const createKey = useCallback(async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
setKeyLoading(true)
|
||||
setKeyError(null)
|
||||
setCreatedKey(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newKeyName.trim() }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Erro ao criar chave')
|
||||
|
||||
setCreatedKey(data.key)
|
||||
setKeys((prev) => [
|
||||
{
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
key: data.key.slice(0, 8) + '••••••••' + data.key.slice(-4),
|
||||
active: true,
|
||||
createdAt: data.createdAt,
|
||||
},
|
||||
...prev,
|
||||
])
|
||||
setNewKeyName('')
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : 'Erro ao criar chave')
|
||||
} finally {
|
||||
setKeyLoading(false)
|
||||
}
|
||||
}, [newKeyName])
|
||||
|
||||
const deleteKey = useCallback(async (id: string) => {
|
||||
setDeletingId(id)
|
||||
try {
|
||||
const res = await fetch(`/api/keys/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || 'Erro ao revogar chave')
|
||||
}
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id))
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : 'Erro ao revogar chave')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopiedKey(true)
|
||||
setTimeout(() => setCopiedKey(false), 2000)
|
||||
}, [])
|
||||
|
||||
const plan = planConfig[user.plan] || planConfig.FREE
|
||||
|
||||
// ── Mock billing history ──
|
||||
const billingHistory = [
|
||||
{ date: '2025-06-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
|
||||
{ date: '2025-05-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
|
||||
{ date: '2025-04-01', description: 'Plano Pro — Mensal', amount: 'R$ 89,90', status: 'Pago' },
|
||||
{ date: '2025-03-15', description: 'Créditos extras (50)', amount: 'R$ 29,90', status: 'Pago' },
|
||||
]
|
||||
|
||||
const initials = user.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Configurações</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Gerencie seu perfil, assinatura, chaves de API e preferências de segurança.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ━━━ 1. Profile ━━━ */}
|
||||
<Section id="perfil" title="Perfil" icon={User} description="Informações da sua conta">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="h-20 w-20 rounded-2xl object-cover ring-2 ring-teal-500/30"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-teal-600/20 text-2xl font-bold text-teal-400 ring-2 ring-teal-500/30">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] uppercase tracking-wider text-zinc-600">
|
||||
Avatar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fields */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
|
||||
<User className="h-3 w-3" /> Nome
|
||||
</label>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
|
||||
{user.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
|
||||
<Mail className="h-3 w-3" /> Email
|
||||
</label>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAB */}
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
|
||||
<Scale className="h-3 w-3" /> OAB
|
||||
</label>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
|
||||
{user.oabNumber && user.oabState
|
||||
? `${user.oabNumber} / ${user.oabState}`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-zinc-500">
|
||||
<Phone className="h-3 w-3" /> Telefone
|
||||
</label>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.03] px-4 py-2.5 text-sm text-white">
|
||||
{user.phone || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-zinc-600">
|
||||
Membro desde {formatDate(user.createdAt)}. Para editar seu perfil, entre em contato com o suporte.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ━━━ 2. Subscription ━━━ */}
|
||||
<Section
|
||||
id="planos"
|
||||
title="Assinatura"
|
||||
icon={CreditCard}
|
||||
description="Seu plano atual e histórico de cobrança"
|
||||
>
|
||||
{/* Current plan */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl bg-teal-600/15 p-3">
|
||||
<plan.icon className="h-6 w-6 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-white">Plano {plan.label}</span>
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${plan.color}`}>
|
||||
Ativo
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
{user.credits} créditos restantes
|
||||
{subscription?.endDate && (
|
||||
<> · Renova em {formatDate(subscription.endDate)}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{user.plan !== 'FREE' && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setPortalLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/stripe/portal', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else alert(data.error || 'Erro')
|
||||
} catch { alert('Erro ao conectar') }
|
||||
finally { setPortalLoading(false) }
|
||||
}}
|
||||
disabled={portalLoading}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-2.5 text-sm font-medium text-zinc-300 transition-all hover:bg-white/[0.06] disabled:opacity-50"
|
||||
>
|
||||
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <CreditCard className="h-4 w-4" />}
|
||||
Gerenciar Assinatura
|
||||
</button>
|
||||
)}
|
||||
{user.plan !== 'ENTERPRISE' && (
|
||||
<a
|
||||
href="/pricing"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-600/20"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Upgrade
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits bar */}
|
||||
<div className="mt-6">
|
||||
<div className="mb-2 flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-500">Créditos usados este mês</span>
|
||||
<span className="font-medium text-zinc-300">
|
||||
{Math.max(0, (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500) - user.credits)} / {user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-teal-600 to-teal-400 transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, ((Math.max(0, (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500) - user.credits)) / (user.plan === 'FREE' ? 5 : user.plan === 'PRO' ? 100 : 500)) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing history */}
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-3 text-sm font-medium text-zinc-400">Histórico de cobrança</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-white/5">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-left text-xs font-medium uppercase tracking-wider text-zinc-600">
|
||||
<th className="px-4 py-2.5">Data</th>
|
||||
<th className="hidden px-4 py-2.5 sm:table-cell">Descrição</th>
|
||||
<th className="px-4 py-2.5 text-right">Valor</th>
|
||||
<th className="px-4 py-2.5 text-right">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{billingHistory.map((item, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.02]">
|
||||
<td className="px-4 py-2.5 text-zinc-400">{formatDate(item.date)}</td>
|
||||
<td className="hidden px-4 py-2.5 text-zinc-300 sm:table-cell">{item.description}</td>
|
||||
<td className="px-4 py-2.5 text-right font-medium text-white">{item.amount}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ━━━ 3. API Keys ━━━ */}
|
||||
<Section
|
||||
id="api-keys"
|
||||
title="Chaves de API"
|
||||
icon={Key}
|
||||
description="Gerencie suas chaves para integração com a API"
|
||||
>
|
||||
{/* Create new key */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nome da chave (ex: meu-app)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createKey()}
|
||||
className="flex-1 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
|
||||
/>
|
||||
<button
|
||||
onClick={createKey}
|
||||
disabled={keyLoading || !newKeyName.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-medium text-white transition-all hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{keyLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Criar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{keyError && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-2.5 text-sm text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Just-created key (show once) */}
|
||||
{createdKey && (
|
||||
<div className="mt-3 rounded-xl border border-teal-500/30 bg-teal-500/10 p-4">
|
||||
<p className="mb-2 text-xs font-medium text-teal-300">
|
||||
⚠️ Copie sua chave agora — ela não será exibida novamente:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded-lg bg-black/40 px-3 py-2 font-mono text-xs text-teal-200">
|
||||
{createdKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(createdKey)}
|
||||
className="rounded-lg bg-teal-600/30 p-2 text-teal-300 transition-colors hover:bg-teal-600/50"
|
||||
>
|
||||
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys list */}
|
||||
{keys.length > 0 ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{keys.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 transition-all ${
|
||||
k.active
|
||||
? 'border-white/5 bg-white/[0.02]'
|
||||
: 'border-white/5 bg-white/[0.01] opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{k.name}</span>
|
||||
{!k.active && (
|
||||
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-medium text-red-400">
|
||||
Revogada
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-600">
|
||||
<code className="font-mono">{k.key}</code>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDateTime(k.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteKey(k.id)}
|
||||
disabled={deletingId === k.id}
|
||||
className="rounded-lg p-2 text-zinc-600 transition-colors hover:bg-red-500/10 hover:text-red-400 disabled:opacity-50"
|
||||
title="Revogar e excluir"
|
||||
>
|
||||
{deletingId === k.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!createdKey && (
|
||||
<div className="mt-4 flex flex-col items-center justify-center rounded-xl border border-dashed border-white/10 py-10 text-center">
|
||||
<Key className="h-8 w-8 text-zinc-700" />
|
||||
<p className="mt-2 text-sm text-zinc-500">Nenhuma chave de API criada</p>
|
||||
<p className="text-xs text-zinc-600">Crie uma chave para integrar com a API do LexMind</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-zinc-600">
|
||||
Máximo de 5 chaves. As chaves têm prefixo <code className="text-zinc-500">jur_</code> e são hasheadas no banco.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* ━━━ Storage ━━━ */}
|
||||
<Section
|
||||
id="armazenamento"
|
||||
title="Armazenamento"
|
||||
icon={HardDrive}
|
||||
description="Espaço usado para uploads de documentos"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-teal-600/15 p-3">
|
||||
<UploadIcon className="h-6 w-6 text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{(storage.used / (1024 * 1024)).toFixed(1)} MB de {(storage.limit / (1024 * 1024 * 1024)).toFixed(0)} GB usados
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">{storage.count} arquivo{storage.count !== 1 ? 's' : ''} enviado{storage.count !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-teal-400">
|
||||
{Math.round((storage.used / storage.limit) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
(storage.used / storage.limit) > 0.9
|
||||
? 'bg-gradient-to-r from-red-600 to-red-400'
|
||||
: (storage.used / storage.limit) > 0.7
|
||||
? 'bg-gradient-to-r from-amber-600 to-amber-400'
|
||||
: 'bg-gradient-to-r from-teal-600 to-teal-400'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (storage.used / storage.limit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-zinc-600">
|
||||
<span>0 GB</span>
|
||||
<span>{(storage.limit / (1024 * 1024 * 1024)).toFixed(0)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ━━━ 4. Notifications ━━━ */}
|
||||
<Section
|
||||
id="notificacoes"
|
||||
title="Notificações"
|
||||
icon={Bell}
|
||||
description="Configure como e quando você recebe alertas"
|
||||
>
|
||||
<div className="divide-y divide-white/5">
|
||||
<Toggle
|
||||
label="Alertas por email"
|
||||
description="Receba notificações importantes por email"
|
||||
icon={Mail}
|
||||
enabled={notifications.emailAlerts}
|
||||
onChange={(v) => setNotifications((p) => ({ ...p, emailAlerts: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Documento pronto"
|
||||
description="Notificar quando uma peça jurídica terminar de ser gerada"
|
||||
icon={FileCheck}
|
||||
enabled={notifications.documentReady}
|
||||
onChange={(v) => setNotifications((p) => ({ ...p, documentReady: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Créditos baixos"
|
||||
description="Avisar quando seus créditos estiverem abaixo de 20%"
|
||||
icon={AlertTriangle}
|
||||
enabled={notifications.creditsLow}
|
||||
onChange={(v) => setNotifications((p) => ({ ...p, creditsLow: v }))}
|
||||
/>
|
||||
<Toggle
|
||||
label="Relatório semanal"
|
||||
description="Resumo da sua atividade enviado toda segunda-feira"
|
||||
icon={BellRing}
|
||||
enabled={notifications.weeklyReport}
|
||||
onChange={(v) => setNotifications((p) => ({ ...p, weeklyReport: v }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button className="rounded-xl bg-teal-600/15 px-4 py-2 text-sm font-medium text-teal-400 transition-colors hover:bg-teal-600/25">
|
||||
Salvar preferências
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ━━━ 5. Security ━━━ */}
|
||||
<Section
|
||||
id="seguranca"
|
||||
title="Segurança"
|
||||
icon={Shield}
|
||||
description="Senha e autenticação em dois fatores"
|
||||
>
|
||||
{/* Change password */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-zinc-300">Alterar senha</h3>
|
||||
|
||||
<div className="max-w-md space-y-3">
|
||||
{/* Current password */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Senha atual</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type={showCurrentPw ? 'text' : 'password'}
|
||||
value={passwordForm.current}
|
||||
onChange={(e) => setPasswordForm((p) => ({ ...p, current: e.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPw(!showCurrentPw)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400"
|
||||
>
|
||||
{showCurrentPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Nova senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type={showNewPw ? 'text' : 'password'}
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm((p) => ({ ...p, newPassword: e.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPw(!showNewPw)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400"
|
||||
>
|
||||
{showNewPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-500">Confirmar nova senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.confirm}
|
||||
onChange={(e) => setPasswordForm((p) => ({ ...p, confirm: e.target.value }))}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/[0.03] py-2.5 pl-10 pr-10 text-sm text-white placeholder-zinc-600 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30"
|
||||
placeholder="Repita a nova senha"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-medium text-white transition-all hover:bg-teal-500">
|
||||
Alterar senha
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA */}
|
||||
<div className="mt-8 flex items-center justify-between rounded-xl border border-white/5 bg-white/[0.02] px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-zinc-800 p-2">
|
||||
<Shield className="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-zinc-300">Autenticação em dois fatores (2FA)</p>
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
|
||||
Em breve
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">
|
||||
Adicione uma camada extra de segurança à sua conta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-700" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ━━━ 6. Danger Zone ━━━ */}
|
||||
<Section
|
||||
id="perigo"
|
||||
title="Zona de Perigo"
|
||||
icon={AlertTriangle}
|
||||
description="Ações irreversíveis"
|
||||
danger
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">Excluir conta permanentemente</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Todos os dados serão perdidos — documentos, chaves, histórico. Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!showDeleteInput ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteInput(true)}
|
||||
className="flex-shrink-0 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-2.5 text-sm font-medium text-red-400 transition-all hover:bg-red-500/20"
|
||||
>
|
||||
Excluir minha conta
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder='Digite "EXCLUIR" para confirmar'
|
||||
value={deleteConfirm}
|
||||
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||
className="rounded-xl border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm text-red-300 placeholder-red-800 outline-none focus:border-red-500/50"
|
||||
/>
|
||||
<button
|
||||
disabled={deleteConfirm !== 'EXCLUIR'}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-red-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteInput(false)
|
||||
setDeleteConfirm('')
|
||||
}}
|
||||
className="rounded-xl px-3 py-2 text-sm text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
src/app/dashboard/configuracoes/page.tsx
Normal file
100
src/app/dashboard/configuracoes/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import SettingsClient from './SettingsClient'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Configurações | LexMind',
|
||||
description: 'Gerencie seu perfil, assinatura, chaves de API e segurança',
|
||||
}
|
||||
|
||||
export default async function ConfiguracoesPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const [user, apiKeys, subscription, storageData] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
oabNumber: true,
|
||||
oabState: true,
|
||||
avatar: true,
|
||||
plan: true,
|
||||
credits: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.apiKey.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
key: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.subscription.findFirst({
|
||||
where: { userId: session.user.id, status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
}),
|
||||
(async () => {
|
||||
const [agg, count] = await Promise.all([
|
||||
prisma.upload.aggregate({
|
||||
where: { userId: session.user.id },
|
||||
_sum: { size: true },
|
||||
}),
|
||||
prisma.upload.count({ where: { userId: session.user.id } }),
|
||||
])
|
||||
const limits: Record<string, number> = {
|
||||
FREE: 1073741824, STARTER: 1073741824,
|
||||
PRO: 5368709120, ENTERPRISE: 21474836480,
|
||||
}
|
||||
const userPlan = (await prisma.user.findUnique({ where: { id: session.user.id }, select: { plan: true } }))?.plan || 'FREE'
|
||||
return {
|
||||
used: agg._sum.size || 0,
|
||||
limit: limits[userPlan] || limits.FREE,
|
||||
count,
|
||||
}
|
||||
})(),
|
||||
])
|
||||
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const maskedKeys = apiKeys.map((k) => ({
|
||||
...k,
|
||||
key: k.key.slice(0, 8) + '••••••••' + k.key.slice(-4),
|
||||
createdAt: k.createdAt.toISOString(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<SettingsClient
|
||||
user={{
|
||||
...user,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}}
|
||||
apiKeys={maskedKeys}
|
||||
subscription={
|
||||
subscription
|
||||
? {
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
startDate: subscription.startDate.toISOString(),
|
||||
endDate: subscription.endDate?.toISOString() ?? null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
storage={{
|
||||
used: storageData.used,
|
||||
limit: storageData.limit,
|
||||
count: storageData.count,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
706
src/app/dashboard/jurisprudencia/page.tsx
Normal file
706
src/app/dashboard/jurisprudencia/page.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles,
|
||||
Scale,
|
||||
Calendar,
|
||||
User,
|
||||
Tag,
|
||||
X,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Brain,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Jurisprudencia {
|
||||
id: string
|
||||
tribunal: string
|
||||
numero: string
|
||||
ementa: string
|
||||
data: string
|
||||
area: string
|
||||
relator: string
|
||||
orgaoJulgador: string
|
||||
tags: string
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: Jurisprudencia[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
interface AISearchResponse {
|
||||
results: Jurisprudencia[]
|
||||
total: number
|
||||
extractedTerms: {
|
||||
keywords: string[]
|
||||
tribunal?: string
|
||||
area?: string
|
||||
relator?: string
|
||||
}
|
||||
aiQuery: string
|
||||
}
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const TRIBUNAIS = [
|
||||
'STF', 'STJ', 'TST',
|
||||
'TRF1', 'TRF2', 'TRF3', 'TRF4', 'TRF5',
|
||||
'TJSP', 'TJRJ', 'TJMG', 'TJRS', 'TJPR', 'TJSC',
|
||||
'TJBA', 'TJPE', 'TJCE', 'TJGO', 'TJDF', 'TJES',
|
||||
'TJMT', 'TJMS', 'TJPA', 'TJMA', 'TJPB', 'TJRN',
|
||||
'TJAL', 'TJSE', 'TJPI', 'TJTO', 'TJRO', 'TJAC',
|
||||
'TJAP', 'TJRR', 'TJAM',
|
||||
]
|
||||
|
||||
const AREAS = [
|
||||
{ value: 'CIVIL', label: 'Civil' },
|
||||
{ value: 'TRABALHISTA', label: 'Trabalhista' },
|
||||
{ value: 'PENAL', label: 'Penal' },
|
||||
{ value: 'TRIBUTARIO', label: 'Tributário' },
|
||||
{ value: 'FAMILIA', label: 'Família' },
|
||||
{ value: 'EMPRESARIAL', label: 'Empresarial' },
|
||||
{ value: 'CONSUMIDOR', label: 'Consumidor' },
|
||||
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
|
||||
]
|
||||
|
||||
const TRIBUNAL_COLORS: Record<string, string> = {
|
||||
STF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
STJ: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
TST: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
TRF1: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
TRF2: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
TRF3: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
TRF4: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
TRF5: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
}
|
||||
|
||||
function getTribunalColor(tribunal: string): string {
|
||||
if (TRIBUNAL_COLORS[tribunal]) return TRIBUNAL_COLORS[tribunal]
|
||||
if (tribunal.startsWith('TJ')) return 'bg-teal-500/20 text-teal-400 border-teal-500/30'
|
||||
return 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30'
|
||||
}
|
||||
|
||||
const AREA_COLORS: Record<string, string> = {
|
||||
CIVIL: 'bg-sky-500/15 text-sky-400',
|
||||
TRABALHISTA: 'bg-amber-500/15 text-amber-400',
|
||||
PENAL: 'bg-red-500/15 text-red-400',
|
||||
TRIBUTARIO: 'bg-emerald-500/15 text-emerald-400',
|
||||
FAMILIA: 'bg-pink-500/15 text-pink-400',
|
||||
EMPRESARIAL: 'bg-indigo-500/15 text-indigo-400',
|
||||
CONSUMIDOR: 'bg-orange-500/15 text-orange-400',
|
||||
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400',
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function JurisprudenciaPage() {
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [aiQuery, setAiQuery] = useState('')
|
||||
const [isAIMode, setIsAIMode] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Filters
|
||||
const [tribunal, setTribunal] = useState('')
|
||||
const [area, setArea] = useState('')
|
||||
const [relator, setRelator] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
|
||||
// Results
|
||||
const [results, setResults] = useState<Jurisprudencia[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
// AI extras
|
||||
const [extractedTerms, setExtractedTerms] = useState<AISearchResponse['extractedTerms'] | null>(null)
|
||||
|
||||
// Expand state
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Copy state
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
const activeFilterCount = [tribunal, area, relator, dateFrom, dateTo].filter(Boolean).length
|
||||
|
||||
// ── Standard search ──
|
||||
|
||||
const doSearch = useCallback(async (p = 1) => {
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
setExtractedTerms(null)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (searchQuery) params.set('search', searchQuery)
|
||||
if (tribunal) params.set('tribunal', tribunal)
|
||||
if (area) params.set('area', area)
|
||||
if (relator) params.set('relator', relator)
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
params.set('page', p.toString())
|
||||
params.set('perPage', '10')
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/jurisprudencia?${params.toString()}`)
|
||||
const data: SearchResponse = await res.json()
|
||||
setResults(data.results)
|
||||
setTotal(data.total)
|
||||
setTotalPages(data.totalPages)
|
||||
setPage(data.page)
|
||||
} catch (err) {
|
||||
console.error('Search error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchQuery, tribunal, area, relator, dateFrom, dateTo])
|
||||
|
||||
// ── AI search ──
|
||||
|
||||
const doAISearch = useCallback(async () => {
|
||||
if (!aiQuery.trim()) return
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/jurisprudencia/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: aiQuery }),
|
||||
})
|
||||
const data: AISearchResponse = await res.json()
|
||||
setResults(data.results)
|
||||
setTotal(data.total)
|
||||
setTotalPages(1)
|
||||
setPage(1)
|
||||
setExtractedTerms(data.extractedTerms)
|
||||
} catch (err) {
|
||||
console.error('AI search error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [aiQuery])
|
||||
|
||||
// ── Handlers ──
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isAIMode) {
|
||||
doAISearch()
|
||||
} else {
|
||||
doSearch(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages) return
|
||||
doSearch(newPage)
|
||||
}
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const copyCitation = (item: Jurisprudencia) => {
|
||||
const tags = parseTags(item.tags)
|
||||
const citation = `${item.tribunal} - ${item.numero} - Rel. ${item.relator} - ${item.orgaoJulgador} - ${formatDate(item.data)}${tags.length ? ` - ${tags.join(', ')}` : ''}\n\nEMENTA: ${item.ementa}`
|
||||
navigator.clipboard.writeText(citation)
|
||||
setCopiedId(item.id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setTribunal('')
|
||||
setArea('')
|
||||
setRelator('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const [y, m, d] = dateStr.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const parseTags = (tagsStr: string): string[] => {
|
||||
try {
|
||||
return JSON.parse(tagsStr)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const truncateEmenta = (text: string, max = 250) => {
|
||||
if (text.length <= max) return text
|
||||
return text.slice(0, max).trimEnd() + '…'
|
||||
}
|
||||
|
||||
// Load initial results on mount
|
||||
useEffect(() => {
|
||||
doSearch(1)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ── */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Jurisprudência</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Pesquise decisões judiciais de tribunais brasileiros
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Search Mode Toggle ── */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsAIMode(false)}
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||
!isAIMode
|
||||
? 'bg-teal-600 text-white shadow-lg shadow-teal-500/20'
|
||||
: 'bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
Busca Padrão
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsAIMode(true)}
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
|
||||
isAIMode
|
||||
? 'bg-gradient-to-r from-teal-600 to-teal-600 text-white shadow-lg shadow-teal-500/20'
|
||||
: 'bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
<Brain className="h-4 w-4" />
|
||||
Busca Inteligente
|
||||
<Sparkles className="h-3 w-3 text-amber-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<form onSubmit={handleSearch} className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||
{isAIMode ? (
|
||||
<Sparkles className="h-5 w-5 text-teal-400" />
|
||||
) : (
|
||||
<Search className="h-5 w-5 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={isAIMode ? aiQuery : searchQuery}
|
||||
onChange={(e) =>
|
||||
isAIMode ? setAiQuery(e.target.value) : setSearchQuery(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
isAIMode
|
||||
? 'Ex: "jurisprudência sobre dano moral em relações de consumo no STJ"'
|
||||
: 'Pesquisar por ementa, número do processo ou relator...'
|
||||
}
|
||||
className={`w-full rounded-xl border bg-white/5 py-3.5 pl-12 pr-32 text-sm text-white placeholder-zinc-500 transition-all focus:outline-none ${
|
||||
isAIMode
|
||||
? 'border-teal-500/30 focus:border-teal-500/60 focus:ring-2 focus:ring-teal-500/20'
|
||||
: 'border-white/10 focus:border-teal-500/40 focus:ring-2 focus:ring-teal-500/10'
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-2 pr-2">
|
||||
{!isAIMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
showFilters || activeFilterCount > 0
|
||||
? 'bg-teal-600/20 text-teal-400'
|
||||
: 'bg-white/5 text-zinc-400 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
Filtros
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-teal-600 text-[10px] font-bold text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-teal-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isAIMode ? (
|
||||
<Zap className="h-4 w-4" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filters Panel ── */}
|
||||
{showFilters && !isAIMode && (
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Filtros avançados
|
||||
</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Limpar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Tribunal */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
|
||||
<Scale className="h-3 w-3" />
|
||||
Tribunal
|
||||
</label>
|
||||
<select
|
||||
value={tribunal}
|
||||
onChange={(e) => setTribunal(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
|
||||
>
|
||||
<option value="" className="bg-[#0f1620]">Todos</option>
|
||||
{TRIBUNAIS.map((t) => (
|
||||
<option key={t} value={t} className="bg-[#0f1620]">
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Área */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
|
||||
<Tag className="h-3 w-3" />
|
||||
Área do Direito
|
||||
</label>
|
||||
<select
|
||||
value={area}
|
||||
onChange={(e) => setArea(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
|
||||
>
|
||||
<option value="" className="bg-[#0f1620]">Todas</option>
|
||||
{AREAS.map((a) => (
|
||||
<option key={a.value} value={a.value} className="bg-[#0f1620]">
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Relator */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
|
||||
<User className="h-3 w-3" />
|
||||
Relator
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={relator}
|
||||
onChange={(e) => setRelator(e.target.value)}
|
||||
placeholder="Nome do relator..."
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-zinc-600 focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date range */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-zinc-400">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Período
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-2 py-2 text-xs text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10 [color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-zinc-600">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-2 py-2 text-xs text-white focus:border-teal-500/40 focus:outline-none focus:ring-2 focus:ring-teal-500/10 [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* ── AI Extracted Terms ── */}
|
||||
{extractedTerms && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-teal-500/20 bg-teal-500/5 p-3">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-teal-400">
|
||||
<Brain className="h-3.5 w-3.5" />
|
||||
Termos extraídos pela IA:
|
||||
</span>
|
||||
{extractedTerms.keywords.map((kw, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-md bg-teal-500/15 px-2 py-0.5 text-xs font-medium text-teal-300"
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
{extractedTerms.tribunal && (
|
||||
<span className="rounded-md bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-300">
|
||||
Tribunal: {extractedTerms.tribunal}
|
||||
</span>
|
||||
)}
|
||||
{extractedTerms.area && (
|
||||
<span className="rounded-md bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-300">
|
||||
Área: {extractedTerms.area}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
{isAIMode ? 'IA analisando sua consulta...' : 'Buscando jurisprudência...'}
|
||||
</p>
|
||||
</div>
|
||||
) : searched && results.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Scale className="h-12 w-12 text-zinc-700" />
|
||||
<p className="mt-3 text-sm font-medium text-zinc-400">
|
||||
Nenhuma jurisprudência encontrada
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
Tente ajustar os termos de busca ou os filtros
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Result count */}
|
||||
{searched && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">
|
||||
<span className="font-medium text-zinc-300">{total}</span>{' '}
|
||||
resultado{total !== 1 ? 's' : ''} encontrado{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards */}
|
||||
<div className="space-y-3">
|
||||
{results.map((item) => {
|
||||
const isExpanded = expandedIds.has(item.id)
|
||||
const isCopied = copiedId === item.id
|
||||
const tags = parseTags(item.tags)
|
||||
const areaLabel = AREAS.find((a) => a.value === item.area)?.label || item.area
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group rounded-xl border border-white/5 bg-white/[0.02] transition-all hover:border-white/10 hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Top row */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Tribunal badge */}
|
||||
<span
|
||||
className={`rounded-md border px-2.5 py-1 text-xs font-bold tracking-wide ${getTribunalColor(
|
||||
item.tribunal
|
||||
)}`}
|
||||
>
|
||||
{item.tribunal}
|
||||
</span>
|
||||
{/* Process number */}
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{item.numero}
|
||||
</span>
|
||||
</div>
|
||||
{/* Date */}
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(item.data)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Relator & Órgão */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="h-3 w-3" />
|
||||
Rel. {item.relator}
|
||||
</span>
|
||||
{item.orgaoJulgador && (
|
||||
<span className="text-zinc-600">• {item.orgaoJulgador}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ementa */}
|
||||
<div className="mt-3">
|
||||
<p className="text-sm leading-relaxed text-zinc-300">
|
||||
{isExpanded ? item.ementa : truncateEmenta(item.ementa)}
|
||||
</p>
|
||||
{item.ementa.length > 250 && (
|
||||
<button
|
||||
onClick={() => toggleExpand(item.id)}
|
||||
className="mt-1.5 flex items-center gap-1 text-xs font-medium text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Mostrar menos
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Ler ementa completa
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags & Actions */}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium ${
|
||||
AREA_COLORS[item.area] || 'bg-zinc-500/15 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{areaLabel}
|
||||
</span>
|
||||
{tags.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] text-zinc-500"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyCitation(item)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
isCopied
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-teal-600/15 text-teal-400 hover:bg-teal-600/25'
|
||||
}`}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Copiado!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Usar em peça
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{totalPages > 1 && !isAIMode && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
let pageNum: number
|
||||
if (totalPages <= 7) {
|
||||
pageNum = i + 1
|
||||
} else if (page <= 4) {
|
||||
pageNum = i + 1
|
||||
} else if (page >= totalPages - 3) {
|
||||
pageNum = totalPages - 6 + i
|
||||
} else {
|
||||
pageNum = page - 3 + i
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition-all ${
|
||||
pageNum === page
|
||||
? 'bg-teal-600 text-white shadow-lg shadow-teal-500/20'
|
||||
: 'text-zinc-500 hover:bg-white/5 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-zinc-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
Próxima
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/app/dashboard/layout.tsx
Normal file
36
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import DashboardShell from './DashboardShell'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard | LexMind',
|
||||
description: 'Painel de controle da plataforma LexMind',
|
||||
}
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
user={{
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
plan: session.user.plan,
|
||||
avatar: session.user.avatar,
|
||||
oabNumber: session.user.oabNumber,
|
||||
oabState: session.user.oabState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DashboardShell>
|
||||
)
|
||||
}
|
||||
287
src/app/dashboard/minhas-pecas/[id]/page.tsx
Normal file
287
src/app/dashboard/minhas-pecas/[id]/page.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { stripMarkdown, downloadAsWord } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft, Copy, Printer, RefreshCw, FileText, Calendar,
|
||||
Hash, DollarSign, Tag, Briefcase, CheckCircle2, Clock,
|
||||
AlertTriangle, Loader2, FileDown
|
||||
} from 'lucide-react'
|
||||
|
||||
const DOCUMENT_TYPES: Record<string, string> = {
|
||||
PETICAO_INICIAL: 'Petição Inicial',
|
||||
CONTESTACAO: 'Contestação',
|
||||
APELACAO: 'Apelação',
|
||||
RECURSO: 'Recurso',
|
||||
CONTRATO: 'Contrato',
|
||||
PARECER: 'Parecer',
|
||||
IMPUGNACAO: 'Impugnação',
|
||||
HABEAS_CORPUS: 'Habeas Corpus',
|
||||
MANDADO_SEGURANCA: 'Mandado de Segurança',
|
||||
OUTROS: 'Outros',
|
||||
}
|
||||
|
||||
const AREAS: Record<string, string> = {
|
||||
CIVIL: 'Civil',
|
||||
TRABALHISTA: 'Trabalhista',
|
||||
PENAL: 'Penal',
|
||||
TRIBUTARIO: 'Tributário',
|
||||
FAMILIA: 'Família',
|
||||
EMPRESARIAL: 'Empresarial',
|
||||
CONSUMIDOR: 'Consumidor',
|
||||
ADMINISTRATIVO: 'Administrativo',
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
COMPLETED: { label: 'Concluído', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', icon: CheckCircle2 },
|
||||
GENERATING: { label: 'Gerando...', color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', icon: Clock },
|
||||
ERROR: { label: 'Erro', color: 'text-red-400 bg-red-400/10 border-red-400/20', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
area: string
|
||||
status: string
|
||||
content: string
|
||||
prompt: string
|
||||
wordCount: number
|
||||
tokens: number
|
||||
cost: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function DocumentViewerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const [doc, setDoc] = useState<Document | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [downloadingWord, setDownloadingWord] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/documents/${id}`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Documento não encontrado')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => setDoc(data.document))
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!doc) return
|
||||
await navigator.clipboard.writeText(stripMarkdown(doc.content))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
const handleDownloadWord = async () => {
|
||||
if (!doc) return
|
||||
setDownloadingWord(true)
|
||||
try {
|
||||
await downloadAsWord(doc.title, doc.content)
|
||||
} catch (err) {
|
||||
console.error('Word download error:', err)
|
||||
} finally {
|
||||
setDownloadingWord(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}).format(new Date(date))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !doc) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] flex flex-col items-center justify-center gap-4">
|
||||
<AlertTriangle className="w-12 h-12 text-red-400" />
|
||||
<h2 className="text-xl font-semibold text-white">{error || 'Documento não encontrado'}</h2>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/minhas-pecas')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-teal-400 hover:text-teal-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Voltar para Minhas Peças
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
|
||||
const StatusIcon = status.icon
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] p-4 md:p-8">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Top Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6"
|
||||
>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/minhas-pecas')}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors w-fit"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Voltar para Minhas Peças
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2 print:hidden">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<Printer className="w-4 h-4" />
|
||||
Imprimir
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadWord}
|
||||
disabled={downloadingWord}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 border border-teal-500/30 rounded-xl transition-all"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
{downloadingWord ? 'Gerando...' : 'Baixar Word'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/gerar')}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-teal-600 hover:bg-teal-700 rounded-xl transition-all glow-purple"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Gerar Nova Versão
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Document Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="glass rounded-2xl p-6 mb-6"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border ${status.color}`}>
|
||||
<StatusIcon className="w-3.5 h-3.5" />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="px-3 py-1.5 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
|
||||
{DOCUMENT_TYPES[doc.type] || doc.type}
|
||||
</span>
|
||||
<span className="px-3 py-1.5 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
|
||||
{AREAS[doc.area] || doc.area}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-4">{doc.title}</h1>
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Data</p>
|
||||
<p className="text-sm text-gray-300">{formatDate(doc.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Palavras</p>
|
||||
<p className="text-sm text-gray-300">{doc.wordCount.toLocaleString('pt-BR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Tokens</p>
|
||||
<p className="text-sm text-gray-300">{doc.tokens.toLocaleString('pt-BR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Custo</p>
|
||||
<p className="text-sm text-gray-300">R$ {doc.cost.toFixed(4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Tipo</p>
|
||||
<p className="text-sm text-gray-300">{DOCUMENT_TYPES[doc.type] || doc.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Prompt (collapsed) */}
|
||||
{doc.prompt && (
|
||||
<motion.details
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="glass rounded-2xl mb-6 print:hidden"
|
||||
>
|
||||
<summary className="px-6 py-4 cursor-pointer text-gray-400 hover:text-gray-300 transition-colors flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Prompt utilizado</span>
|
||||
</summary>
|
||||
<div className="px-6 pb-5 text-sm text-gray-400 whitespace-pre-wrap border-t border-white/5 pt-4">
|
||||
{doc.prompt}
|
||||
</div>
|
||||
</motion.details>
|
||||
)}
|
||||
|
||||
{/* Document Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="glass rounded-2xl p-6 md:p-10"
|
||||
>
|
||||
<div className="prose prose-invert prose-purple max-w-none
|
||||
prose-headings:text-white prose-headings:font-bold
|
||||
prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg
|
||||
prose-p:text-gray-300 prose-p:leading-relaxed
|
||||
prose-strong:text-white
|
||||
prose-a:text-teal-400 prose-a:no-underline hover:prose-a:underline
|
||||
prose-blockquote:border-teal-500/40 prose-blockquote:bg-teal-500/5 prose-blockquote:rounded-r-lg prose-blockquote:py-1
|
||||
prose-code:text-teal-300 prose-code:bg-teal-500/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
|
||||
prose-li:text-gray-300
|
||||
prose-hr:border-white/10
|
||||
">
|
||||
<ReactMarkdown>{doc.content}</ReactMarkdown>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
519
src/app/dashboard/minhas-pecas/page.tsx
Normal file
519
src/app/dashboard/minhas-pecas/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
FileText, Search, Filter, Grid3X3, List, Eye, Copy, Trash2, Upload,
|
||||
ChevronDown, Calendar, ArrowUpDown, Plus, Loader2, AlertTriangle,
|
||||
CheckCircle2, Clock, X
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const DOCUMENT_TYPES: Record<string, string> = {
|
||||
PETICAO_INICIAL: 'Petição Inicial',
|
||||
CONTESTACAO: 'Contestação',
|
||||
APELACAO: 'Apelação',
|
||||
RECURSO: 'Recurso',
|
||||
CONTRATO: 'Contrato',
|
||||
PARECER: 'Parecer',
|
||||
IMPUGNACAO: 'Impugnação',
|
||||
HABEAS_CORPUS: 'Habeas Corpus',
|
||||
MANDADO_SEGURANCA: 'Mandado de Segurança',
|
||||
OUTROS: 'Outros',
|
||||
}
|
||||
|
||||
const AREAS: Record<string, string> = {
|
||||
CIVIL: 'Civil',
|
||||
TRABALHISTA: 'Trabalhista',
|
||||
PENAL: 'Penal',
|
||||
TRIBUTARIO: 'Tributário',
|
||||
FAMILIA: 'Família',
|
||||
EMPRESARIAL: 'Empresarial',
|
||||
CONSUMIDOR: 'Consumidor',
|
||||
ADMINISTRATIVO: 'Administrativo',
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
|
||||
COMPLETED: { label: 'Concluído', color: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', icon: CheckCircle2 },
|
||||
GENERATING: { label: 'Gerando...', color: 'text-amber-400 bg-amber-400/10 border-amber-400/20', icon: Clock },
|
||||
ERROR: { label: 'Erro', color: 'text-red-400 bg-red-400/10 border-red-400/20', icon: AlertTriangle },
|
||||
}
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
area: string
|
||||
status: string
|
||||
wordCount: number
|
||||
tokens: number
|
||||
cost: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export default function MinhasPecasPage() {
|
||||
const router = useRouter()
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [activeTab, setActiveTab] = useState<'documents' | 'uploads'>('documents')
|
||||
const [search, setSearch] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [areaFilter, setAreaFilter] = useState('')
|
||||
const [sort, setSort] = useState('newest')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (typeFilter) params.set('type', typeFilter)
|
||||
if (areaFilter) params.set('area', areaFilter)
|
||||
if (sort) params.set('sort', sort)
|
||||
if (dateFrom) params.set('dateFrom', dateFrom)
|
||||
if (dateTo) params.set('dateTo', dateTo)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents?${params.toString()}`)
|
||||
const data = await res.json()
|
||||
setDocuments(data.documents || [])
|
||||
} catch {
|
||||
setDocuments([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, typeFilter, areaFilter, sort, dateFrom, dateTo])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(fetchDocuments, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [fetchDocuments])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await fetch(`/api/documents/${deleteId}`, { method: 'DELETE' })
|
||||
setDocuments(prev => prev.filter(d => d.id !== deleteId))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${id}`)
|
||||
const { document } = await res.json()
|
||||
await navigator.clipboard.writeText(document.content)
|
||||
setCopied(id)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(date))
|
||||
|
||||
const hasFilters = typeFilter || areaFilter || dateFrom || dateTo || search
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold gradient-text">Minhas Peças</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{loading ? 'Carregando...' : `${documents.length} documento${documents.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/gerar')}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-700 rounded-xl font-semibold transition-all glow-purple"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Nova Peça
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
activeTab === 'documents'
|
||||
? 'bg-teal-600/20 text-teal-400 border border-teal-500/30'
|
||||
: 'bg-white/5 text-gray-400 border border-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Documentos
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('uploads')}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-all ${
|
||||
activeTab === 'uploads'
|
||||
? 'bg-teal-600/20 text-teal-400 border border-teal-500/30'
|
||||
: 'bg-white/5 text-gray-400 border border-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Uploads
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'uploads' ? (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Meus Uploads</h2>
|
||||
<FileUpload label="Meus Uploads" maxFiles={50} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filters Bar */}
|
||||
<div className="glass rounded-2xl p-4 mb-6 space-y-4">
|
||||
{/* Search + View Toggle */}
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por título..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-3 rounded-xl border transition-all ${viewMode === 'grid' ? 'bg-teal-600/20 border-teal-500/40 text-teal-400' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
<Grid3X3 className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-3 rounded-xl border transition-all ${viewMode === 'list' ? 'bg-teal-600/20 border-teal-500/40 text-teal-400' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white'}`}
|
||||
>
|
||||
<List className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdowns */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="">Todos os tipos</option>
|
||||
{Object.entries(DOCUMENT_TYPES).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Area Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => setAreaFilter(e.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="">Todas as áreas</option>
|
||||
{Object.entries(AREAS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50 cursor-pointer"
|
||||
>
|
||||
<option value="newest">Mais recentes</option>
|
||||
<option value="oldest">Mais antigos</option>
|
||||
<option value="type">Por tipo</option>
|
||||
</select>
|
||||
<ArrowUpDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50"
|
||||
/>
|
||||
<span className="text-gray-500 text-sm">até</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="px-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-sm text-gray-300 focus:outline-none focus:border-teal-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setTypeFilter(''); setAreaFilter(''); setDateFrom(''); setDateTo(''); setSort('newest') }}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs text-red-400 hover:text-red-300 bg-red-400/10 border border-red-400/20 rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-3 h-3" /> Limpar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && documents.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center justify-center py-24 text-center"
|
||||
>
|
||||
<div className="w-20 h-20 rounded-2xl bg-teal-600/10 border border-teal-500/20 flex items-center justify-center mb-6">
|
||||
<FileText className="w-10 h-10 text-teal-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
Nenhuma peça criada ainda
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-6 max-w-md">
|
||||
Comece agora! Use nossa IA para gerar petições, contratos, pareceres e muito mais em segundos.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/gerar')}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-teal-600 hover:bg-teal-700 rounded-xl font-semibold transition-all glow-purple"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Criar Primeira Peça
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Document Grid */}
|
||||
{!loading && documents.length > 0 && viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{documents.map((doc, i) => {
|
||||
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
|
||||
const StatusIcon = status.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={doc.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
|
||||
className="glass rounded-2xl p-5 cursor-pointer hover:border-teal-500/30 transition-all group"
|
||||
>
|
||||
{/* Status & Date */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${status.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{formatDate(doc.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-white font-semibold text-lg mb-3 line-clamp-2 group-hover:text-teal-300 transition-colors">
|
||||
{doc.title}
|
||||
</h3>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<span className="px-2.5 py-1 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
|
||||
{DOCUMENT_TYPES[doc.type] || doc.type}
|
||||
</span>
|
||||
<span className="px-2.5 py-1 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
|
||||
{AREAS[doc.area] || doc.area}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Word Count */}
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
{doc.wordCount.toLocaleString('pt-BR')} palavras
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-300 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 transition-all"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" /> Ver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopy(doc.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-300 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 transition-all"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" /> {copied === doc.id ? 'Copiado!' : 'Copiar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(doc.id)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-red-400 hover:text-red-300 bg-red-400/5 hover:bg-red-400/10 rounded-lg border border-red-400/10 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Excluir
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document List View */}
|
||||
{!loading && documents.length > 0 && viewMode === 'list' && (
|
||||
<div className="glass rounded-2xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider">Título</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Tipo</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Área</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden lg:table-cell">Palavras</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-gray-400 uppercase tracking-wider hidden md:table-cell">Data</th>
|
||||
<th className="px-5 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{documents.map((doc) => {
|
||||
const status = STATUS_CONFIG[doc.status] || STATUS_CONFIG.COMPLETED
|
||||
const StatusIcon = status.icon
|
||||
return (
|
||||
<motion.tr
|
||||
key={doc.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => router.push(`/dashboard/minhas-pecas/${doc.id}`)}
|
||||
className="border-b border-white/5 hover:bg-white/[0.02] cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<span className="text-white font-medium hover:text-teal-300 transition-colors">{doc.title}</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden md:table-cell">
|
||||
<span className="px-2.5 py-1 text-xs font-medium text-teal-300 bg-teal-500/10 border border-teal-500/20 rounded-full">
|
||||
{DOCUMENT_TYPES[doc.type] || doc.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden md:table-cell">
|
||||
<span className="px-2.5 py-1 text-xs font-medium text-blue-300 bg-blue-500/10 border border-blue-500/20 rounded-full">
|
||||
{AREAS[doc.area] || doc.area}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-gray-400 hidden lg:table-cell">
|
||||
{doc.wordCount.toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full border ${status.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-gray-400 hidden md:table-cell">{formatDate(doc.createdAt)}</td>
|
||||
<td className="px-5 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex gap-1.5 justify-end">
|
||||
<button onClick={() => handleCopy(doc.id)} className="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-white/5 transition-all" title="Copiar">
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => setDeleteId(doc.id)} className="p-2 text-gray-400 hover:text-red-400 rounded-lg hover:bg-red-400/5 transition-all" title="Excluir">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{deleteId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={() => !deleting && setDeleteId(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass-strong rounded-2xl p-6 max-w-sm w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-400/10 rounded-xl">
|
||||
<Trash2 className="w-6 h-6 text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Excluir documento?</h3>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
Esta ação não pode ser desfeita. O documento será removido permanentemente.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteId(null)}
|
||||
disabled={deleting}
|
||||
className="flex-1 py-2.5 text-sm font-medium text-gray-300 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="flex-1 py-2.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
{deleting ? 'Excluindo...' : 'Excluir'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
src/app/dashboard/modelos/[id]/page.tsx
Normal file
302
src/app/dashboard/modelos/[id]/page.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
Globe,
|
||||
User,
|
||||
Calendar,
|
||||
Code2,
|
||||
FileText,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'PETICAO_INICIAL', label: 'Petição Inicial' },
|
||||
{ value: 'CONTESTACAO', label: 'Contestação' },
|
||||
{ value: 'APELACAO', label: 'Apelação' },
|
||||
{ value: 'RECURSO', label: 'Recurso' },
|
||||
{ value: 'CONTRATO', label: 'Contrato' },
|
||||
{ value: 'PARECER', label: 'Parecer' },
|
||||
{ value: 'IMPUGNACAO', label: 'Impugnação' },
|
||||
{ value: 'HABEAS_CORPUS', label: 'Habeas Corpus' },
|
||||
{ value: 'MANDADO_SEGURANCA', label: 'Mandado de Segurança' },
|
||||
{ value: 'OUTROS', label: 'Outros' },
|
||||
]
|
||||
|
||||
const AREA_OPTIONS = [
|
||||
{ value: 'CIVIL', label: 'Civil' },
|
||||
{ value: 'TRABALHISTA', label: 'Trabalhista' },
|
||||
{ value: 'PENAL', label: 'Penal' },
|
||||
{ value: 'TRIBUTARIO', label: 'Tributário' },
|
||||
{ value: 'FAMILIA', label: 'Família' },
|
||||
{ value: 'EMPRESARIAL', label: 'Empresarial' },
|
||||
{ value: 'CONSUMIDOR', label: 'Consumidor' },
|
||||
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
|
||||
]
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
PETICAO_INICIAL: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
CONTESTACAO: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
APELACAO: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||
RECURSO: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
CONTRATO: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||
PARECER: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
IMPUGNACAO: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
|
||||
HABEAS_CORPUS: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
MANDADO_SEGURANCA: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
|
||||
OUTROS: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
|
||||
}
|
||||
|
||||
const AREA_COLORS: Record<string, string> = {
|
||||
CIVIL: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
||||
TRABALHISTA: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
||||
PENAL: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
TRIBUTARIO: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
FAMILIA: 'bg-pink-500/15 text-pink-400 border-pink-500/30',
|
||||
EMPRESARIAL: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
CONSUMIDOR: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
}
|
||||
|
||||
function getTypeLabel(value: string) {
|
||||
return TYPE_OPTIONS.find((t) => t.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function getAreaLabel(value: string) {
|
||||
return AREA_OPTIONS.find((a) => a.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
area: string
|
||||
prompt: string
|
||||
isPublic: boolean
|
||||
userId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ── Page ──
|
||||
|
||||
export default function TemplateDetailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
const [template, setTemplate] = useState<Template | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTemplate() {
|
||||
try {
|
||||
const res = await fetch('/api/templates')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const found = data.templates.find((t: Template) => t.id === id)
|
||||
if (found) {
|
||||
setTemplate(found)
|
||||
} else {
|
||||
setNotFound(true)
|
||||
}
|
||||
} else {
|
||||
setNotFound(true)
|
||||
}
|
||||
} catch {
|
||||
setNotFound(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchTemplate()
|
||||
}, [id])
|
||||
|
||||
const handleUse = () => {
|
||||
if (!template) return
|
||||
const qp = new URLSearchParams({
|
||||
templateId: template.id,
|
||||
type: template.type,
|
||||
area: template.area,
|
||||
templateName: template.name,
|
||||
})
|
||||
router.push(`/dashboard/nova-peca?${qp.toString()}`)
|
||||
}
|
||||
|
||||
const handleCopyPrompt = async () => {
|
||||
if (!template) return
|
||||
await navigator.clipboard.writeText(template.prompt)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (notFound || !template) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-600/10">
|
||||
<FileText className="h-8 w-8 text-red-500/60" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-semibold text-white">Modelo não encontrado</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">Este modelo não existe ou você não tem acesso.</p>
|
||||
<Link
|
||||
href="/dashboard/modelos"
|
||||
className="mt-4 flex items-center gap-2 rounded-xl bg-white/5 px-5 py-2.5 text-sm font-medium text-zinc-300 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar aos Modelos
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const variables = template.prompt.match(/\{\{(\w+)\}\}/g)?.map((v) => v.slice(2, -2)) ?? []
|
||||
const createdDate = new Date(template.createdAt).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
// Highlight {{variables}} in the prompt
|
||||
const highlightedPrompt = template.prompt.split(/(\{\{\w+\}\})/).map((part, i) => {
|
||||
if (/^\{\{\w+\}\}$/.test(part)) {
|
||||
return (
|
||||
<span key={i} className="rounded bg-teal-600/20 px-1 text-teal-400">
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back */}
|
||||
<Link
|
||||
href="/dashboard/modelos"
|
||||
className="inline-flex items-center gap-2 text-sm text-zinc-400 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar aos Modelos
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
TYPE_COLORS[template.type] ?? TYPE_COLORS.OUTROS
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(template.type)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-bold uppercase tracking-wide ${
|
||||
AREA_COLORS[template.area] ?? AREA_COLORS.CIVIL
|
||||
}`}
|
||||
>
|
||||
{getAreaLabel(template.area)}
|
||||
</span>
|
||||
{template.isPublic ? (
|
||||
<span className="flex items-center gap-1 rounded-lg border border-teal-500/30 bg-teal-500/15 px-2.5 py-1 text-xs font-bold text-teal-400">
|
||||
<Globe className="h-3 w-3" />
|
||||
Sistema
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 rounded-lg border border-emerald-500/30 bg-emerald-500/15 px-2.5 py-1 text-xs font-bold text-emerald-400">
|
||||
<User className="h-3 w-3" />
|
||||
Meu Modelo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">{template.name}</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-zinc-400">{template.description}</p>
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Criado em {createdDate}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUse}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 hover:shadow-teal-500/30"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Usar este modelo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Variables */}
|
||||
{variables.length > 0 && (
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<Code2 className="h-4 w-4 text-teal-400" />
|
||||
Variáveis do Template ({variables.length})
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{variables.map((v, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg border border-teal-500/20 bg-teal-600/10 px-3 py-1.5 font-mono text-sm text-teal-300"
|
||||
>
|
||||
{`{{${v}}}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
Estas variáveis serão preenchidas automaticamente ao usar o modelo para gerar uma peça.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt Preview */}
|
||||
<div className="rounded-2xl border border-white/5 bg-white/[0.02] p-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<FileText className="h-4 w-4 text-teal-400" />
|
||||
Prompt do Template
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-emerald-400">Copiado!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copiar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl bg-black/30 p-4 font-mono text-sm leading-relaxed text-zinc-300">
|
||||
<pre className="whitespace-pre-wrap break-words">{highlightedPrompt}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
662
src/app/dashboard/modelos/page.tsx
Normal file
662
src/app/dashboard/modelos/page.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BookTemplate,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
X,
|
||||
Sparkles,
|
||||
Globe,
|
||||
User,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
FileText,
|
||||
Code2,
|
||||
} from 'lucide-react'
|
||||
|
||||
// ── Constants ──
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'PETICAO_INICIAL', label: 'Petição Inicial' },
|
||||
{ value: 'CONTESTACAO', label: 'Contestação' },
|
||||
{ value: 'APELACAO', label: 'Apelação' },
|
||||
{ value: 'RECURSO', label: 'Recurso' },
|
||||
{ value: 'CONTRATO', label: 'Contrato' },
|
||||
{ value: 'PARECER', label: 'Parecer' },
|
||||
{ value: 'IMPUGNACAO', label: 'Impugnação' },
|
||||
{ value: 'HABEAS_CORPUS', label: 'Habeas Corpus' },
|
||||
{ value: 'MANDADO_SEGURANCA', label: 'Mandado de Segurança' },
|
||||
{ value: 'OUTROS', label: 'Outros' },
|
||||
]
|
||||
|
||||
const AREA_OPTIONS = [
|
||||
{ value: 'CIVIL', label: 'Civil' },
|
||||
{ value: 'TRABALHISTA', label: 'Trabalhista' },
|
||||
{ value: 'PENAL', label: 'Penal' },
|
||||
{ value: 'TRIBUTARIO', label: 'Tributário' },
|
||||
{ value: 'FAMILIA', label: 'Família' },
|
||||
{ value: 'EMPRESARIAL', label: 'Empresarial' },
|
||||
{ value: 'CONSUMIDOR', label: 'Consumidor' },
|
||||
{ value: 'ADMINISTRATIVO', label: 'Administrativo' },
|
||||
]
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
PETICAO_INICIAL: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
CONTESTACAO: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
APELACAO: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
||||
RECURSO: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
CONTRATO: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
||||
PARECER: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
|
||||
IMPUGNACAO: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
|
||||
HABEAS_CORPUS: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
MANDADO_SEGURANCA: 'bg-indigo-500/15 text-indigo-400 border-indigo-500/30',
|
||||
OUTROS: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30',
|
||||
}
|
||||
|
||||
const AREA_COLORS: Record<string, string> = {
|
||||
CIVIL: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
|
||||
TRABALHISTA: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
|
||||
PENAL: 'bg-red-500/15 text-red-400 border-red-500/30',
|
||||
TRIBUTARIO: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
FAMILIA: 'bg-pink-500/15 text-pink-400 border-pink-500/30',
|
||||
EMPRESARIAL: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
CONSUMIDOR: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
||||
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
|
||||
}
|
||||
|
||||
function getTypeLabel(value: string) {
|
||||
return TYPE_OPTIONS.find((t) => t.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function getAreaLabel(value: string) {
|
||||
return AREA_OPTIONS.find((a) => a.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
// ── Types ──
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
area: string
|
||||
prompt: string
|
||||
isPublic: boolean
|
||||
userId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
|
||||
export default function ModelosPage() {
|
||||
const router = useRouter()
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterArea, setFilterArea] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formDescription, setFormDescription] = useState('')
|
||||
const [formType, setFormType] = useState('')
|
||||
const [formArea, setFormArea] = useState('')
|
||||
const [formPrompt, setFormPrompt] = useState('')
|
||||
const [formError, setFormError] = useState('')
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (filterType) params.set('type', filterType)
|
||||
if (filterArea) params.set('area', filterArea)
|
||||
|
||||
const res = await fetch(`/api/templates?${params.toString()}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setTemplates(data.templates)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, filterType, filterArea])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(fetchTemplates, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [fetchTemplates])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
|
||||
if (!formName || !formDescription || !formType || !formArea || !formPrompt) {
|
||||
setFormError('Preencha todos os campos.')
|
||||
return
|
||||
}
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formName,
|
||||
description: formDescription,
|
||||
type: formType,
|
||||
area: formArea,
|
||||
prompt: formPrompt,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setFormError(data.error || 'Erro ao criar modelo.')
|
||||
return
|
||||
}
|
||||
|
||||
// Reset and refresh
|
||||
setFormName('')
|
||||
setFormDescription('')
|
||||
setFormType('')
|
||||
setFormArea('')
|
||||
setFormPrompt('')
|
||||
setShowCreateForm(false)
|
||||
fetchTemplates()
|
||||
} catch {
|
||||
setFormError('Erro de conexão.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseTemplate = (template: Template) => {
|
||||
const params = new URLSearchParams({
|
||||
templateId: template.id,
|
||||
type: template.type,
|
||||
area: template.area,
|
||||
templateName: template.name,
|
||||
})
|
||||
router.push(`/dashboard/nova-peca?${params.toString()}`)
|
||||
}
|
||||
|
||||
const activeFilters = (filterType ? 1 : 0) + (filterArea ? 1 : 0)
|
||||
const publicTemplates = templates.filter((t) => t.isPublic)
|
||||
const userTemplates = templates.filter((t) => !t.isPublic)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
|
||||
<BookTemplate className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
Modelos
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Use modelos prontos ou crie os seus para agilizar a geração de peças.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 hover:shadow-teal-500/30"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Criar Modelo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar modelos..."
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-10 pr-4 text-sm text-white placeholder-zinc-500 outline-none transition-colors focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
showFilters || activeFilters > 0
|
||||
? 'border-teal-500/50 bg-teal-600/10 text-teal-400'
|
||||
: 'border-white/10 bg-white/5 text-zinc-400 hover:border-white/20 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros
|
||||
{activeFilters > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-teal-600 text-[10px] font-bold text-white">
|
||||
{activeFilters}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdowns */}
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap gap-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Tipo</label>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-[#0f1620] px-3 py-2 text-sm text-white outline-none focus:border-teal-500/50"
|
||||
>
|
||||
<option value="">Todos os tipos</option>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label className="mb-1.5 block text-xs font-medium text-zinc-400">Área</label>
|
||||
<select
|
||||
value={filterArea}
|
||||
onChange={(e) => setFilterArea(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-[#0f1620] px-3 py-2 text-sm text-white outline-none focus:border-teal-500/50"
|
||||
>
|
||||
<option value="">Todas as áreas</option>
|
||||
{AREA_OPTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{activeFilters > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterType('')
|
||||
setFilterArea('')
|
||||
}}
|
||||
className="flex items-center gap-1 self-end rounded-lg px-3 py-2 text-xs text-zinc-400 hover:text-red-400"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Limpar filtros
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && templates.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-white/5 bg-white/[0.02] py-20">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-teal-600/10">
|
||||
<BookTemplate className="h-8 w-8 text-teal-500/60" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">Nenhum modelo encontrado</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{search || filterType || filterArea
|
||||
? 'Tente ajustar seus filtros de busca.'
|
||||
: 'Crie seu primeiro modelo personalizado.'}
|
||||
</p>
|
||||
{!search && !filterType && !filterArea && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="mt-4 flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-teal-500"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Criar Modelo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public Templates */}
|
||||
{!loading && publicTemplates.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Modelos do Sistema
|
||||
</h2>
|
||||
<span className="rounded-full bg-teal-600/15 px-2 py-0.5 text-[10px] font-bold text-teal-400">
|
||||
{publicTemplates.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{publicTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onUse={() => handleUseTemplate(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* User Templates */}
|
||||
{!loading && userTemplates.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-emerald-400" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
|
||||
Meus Modelos
|
||||
</h2>
|
||||
<span className="rounded-full bg-emerald-600/15 px-2 py-0.5 text-[10px] font-bold text-emerald-400">
|
||||
{userTemplates.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{userTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onUse={() => handleUseTemplate(template)}
|
||||
isOwn
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateForm && (
|
||||
<CreateTemplateModal
|
||||
formName={formName}
|
||||
setFormName={setFormName}
|
||||
formDescription={formDescription}
|
||||
setFormDescription={setFormDescription}
|
||||
formType={formType}
|
||||
setFormType={setFormType}
|
||||
formArea={formArea}
|
||||
setFormArea={setFormArea}
|
||||
formPrompt={formPrompt}
|
||||
setFormPrompt={setFormPrompt}
|
||||
formError={formError}
|
||||
creating={creating}
|
||||
onSubmit={handleCreate}
|
||||
onClose={() => {
|
||||
setShowCreateForm(false)
|
||||
setFormError('')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Template Card ──
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onUse,
|
||||
isOwn,
|
||||
}: {
|
||||
template: Template
|
||||
onUse: () => void
|
||||
isOwn?: boolean
|
||||
}) {
|
||||
// Extract {{variables}} from prompt
|
||||
const variables = template.prompt.match(/\{\{(\w+)\}\}/g)?.map((v) => v.slice(2, -2)) ?? []
|
||||
|
||||
return (
|
||||
<div className="group relative flex flex-col rounded-2xl border border-white/5 bg-white/[0.02] p-5 transition-all duration-200 hover:border-teal-500/30 hover:bg-teal-600/[0.04] hover:shadow-lg hover:shadow-teal-600/5">
|
||||
{/* Badges */}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`rounded-lg border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
TYPE_COLORS[template.type] ?? TYPE_COLORS.OUTROS
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(template.type)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-lg border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
AREA_COLORS[template.area] ?? AREA_COLORS.CIVIL
|
||||
}`}
|
||||
>
|
||||
{getAreaLabel(template.area)}
|
||||
</span>
|
||||
{isOwn && (
|
||||
<span className="rounded-lg border border-emerald-500/30 bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400">
|
||||
Meu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name & Description */}
|
||||
<h3 className="text-base font-semibold text-white group-hover:text-teal-300 transition-colors">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="mt-1.5 flex-1 text-sm leading-relaxed text-zinc-500 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
{/* Variables preview */}
|
||||
{variables.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{variables.slice(0, 4).map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="flex items-center gap-1 rounded-md bg-zinc-800/80 px-1.5 py-0.5 text-[10px] font-mono text-zinc-400"
|
||||
>
|
||||
<Code2 className="h-2.5 w-2.5" />
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
{variables.length > 4 && (
|
||||
<span className="rounded-md bg-zinc-800/80 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
+{variables.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-teal-600/15 py-2 text-sm font-semibold text-teal-400 transition-all hover:bg-teal-600 hover:text-white hover:shadow-lg hover:shadow-teal-600/20"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Usar modelo
|
||||
</button>
|
||||
<Link
|
||||
href={`/dashboard/modelos/${template.id}`}
|
||||
className="flex items-center justify-center rounded-xl border border-white/10 p-2 text-zinc-400 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Create Modal ──
|
||||
|
||||
function CreateTemplateModal({
|
||||
formName,
|
||||
setFormName,
|
||||
formDescription,
|
||||
setFormDescription,
|
||||
formType,
|
||||
setFormType,
|
||||
formArea,
|
||||
setFormArea,
|
||||
formPrompt,
|
||||
setFormPrompt,
|
||||
formError,
|
||||
creating,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
formName: string
|
||||
setFormName: (v: string) => void
|
||||
formDescription: string
|
||||
setFormDescription: (v: string) => void
|
||||
formType: string
|
||||
setFormType: (v: string) => void
|
||||
formArea: string
|
||||
setFormArea: (v: string) => void
|
||||
formPrompt: string
|
||||
setFormPrompt: (v: string) => void
|
||||
formError: string
|
||||
creating: boolean
|
||||
onSubmit: (e: React.FormEvent) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0f1620] p-6 shadow-2xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-xl font-bold text-white">
|
||||
<Plus className="h-5 w-5 text-teal-400" />
|
||||
Criar Modelo Personalizado
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-zinc-400 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Nome do modelo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Ex: Petição de Danos Morais"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-zinc-500 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Descrição</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Breve descrição do que este modelo gera"
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-zinc-500 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type & Area */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Tipo</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value)}
|
||||
className="w-full rounded-xl border border-white/10 bg-[#0a1018] px-4 py-2.5 text-sm text-white outline-none focus:border-teal-500/50"
|
||||
>
|
||||
<option value="">Selecione o tipo</option>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">Área</label>
|
||||
<select
|
||||
value={formArea}
|
||||
onChange={(e) => setFormArea(e.target.value)}
|
||||
className="w-full rounded-xl border border-white/10 bg-[#0a1018] px-4 py-2.5 text-sm text-white outline-none focus:border-teal-500/50"
|
||||
>
|
||||
<option value="">Selecione a área</option>
|
||||
{AREA_OPTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt Template */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-zinc-300">
|
||||
Template do Prompt
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-zinc-500">
|
||||
Use <code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{variavel}}'}</code> para variáveis
|
||||
dinâmicas. Ex:{' '}
|
||||
<code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{nome_autor}}'}</code>,{' '}
|
||||
<code className="rounded bg-zinc-800 px-1 py-0.5 font-mono text-teal-400">{'{{valor_causa}}'}</code>
|
||||
</p>
|
||||
<textarea
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
placeholder={`Elabore uma petição inicial de indenização por danos morais em face de {{nome_reu}}, em favor de {{nome_autor}}, no valor de {{valor_causa}}.\n\nFatos: {{fatos}}\n\nFundamentos jurídicos: CDC, art. 14 e CC, art. 186.`}
|
||||
rows={6}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 font-mono text-sm text-white placeholder-zinc-600 outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/25"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{formError && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2.5 text-sm text-red-400">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-white/10 px-5 py-2.5 text-sm font-medium text-zinc-400 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-teal-600/20 transition-all hover:bg-teal-500 disabled:opacity-50"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Criar Modelo
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
882
src/app/dashboard/nova-peca/page.tsx
Normal file
882
src/app/dashboard/nova-peca/page.tsx
Normal file
@@ -0,0 +1,882 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { motion, AnimatePresence, type Variants } from 'framer-motion'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import FileUpload from '@/components/FileUpload'
|
||||
import { stripMarkdown, downloadAsWord } from "@/lib/utils"
|
||||
import {
|
||||
FileText, Scale, Gavel, Shield, BookOpen, FileCheck,
|
||||
AlertTriangle, Lock, ScrollText, Landmark, Ban, ArrowUpRight,
|
||||
Hammer, MoreHorizontal, ChevronRight, ChevronLeft, Check,
|
||||
Users, ClipboardList, BookMarked, MessageSquare, Upload,
|
||||
Sparkles, Copy, Download, Edit3, RotateCcw, Loader2,
|
||||
Home, Briefcase, Heart, Building2, ShoppingCart, Settings,
|
||||
ArrowLeft, Eye, Zap, CheckCircle2
|
||||
} from 'lucide-react'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
interface DocumentType {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
description: string
|
||||
}
|
||||
|
||||
interface LegalArea {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
}
|
||||
|
||||
interface FormDetails {
|
||||
autor: string
|
||||
reu: string
|
||||
fatos: string
|
||||
fundamentos: string
|
||||
pedidos: string
|
||||
contexto: string
|
||||
}
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────
|
||||
|
||||
const DOCUMENT_TYPES: DocumentType[] = [
|
||||
{ id: 'peticao-inicial', name: 'Petição Inicial', icon: <FileText className="w-6 h-6" />, description: 'Peça inaugural do processo judicial' },
|
||||
{ id: 'contestacao', name: 'Contestação', icon: <Shield className="w-6 h-6" />, description: 'Resposta do réu à ação judicial' },
|
||||
{ id: 'apelacao-civel', name: 'Apelação Cível', icon: <ArrowUpRight className="w-6 h-6" />, description: 'Recurso contra sentença cível' },
|
||||
{ id: 'apelacao-criminal', name: 'Apelação Criminal', icon: <Gavel className="w-6 h-6" />, description: 'Recurso contra sentença criminal' },
|
||||
{ id: 'recurso', name: 'Recurso', icon: <RotateCcw className="w-6 h-6" />, description: 'Recurso genérico (ordinário/inominado)' },
|
||||
{ id: 'contrato', name: 'Contrato', icon: <FileCheck className="w-6 h-6" />, description: 'Contratos e instrumentos particulares' },
|
||||
{ id: 'parecer', name: 'Parecer', icon: <BookOpen className="w-6 h-6" />, description: 'Opinião jurídica fundamentada' },
|
||||
{ id: 'impugnacao', name: 'Impugnação', icon: <AlertTriangle className="w-6 h-6" />, description: 'Impugnação ao cumprimento de sentença' },
|
||||
{ id: 'habeas-corpus', name: 'Habeas Corpus', icon: <Lock className="w-6 h-6" />, description: 'Proteção da liberdade de locomoção' },
|
||||
{ id: 'mandado-seguranca', name: 'Mandado de Segurança', icon: <Landmark className="w-6 h-6" />, description: 'Proteção de direito líquido e certo' },
|
||||
{ id: 'embargo', name: 'Embargo', icon: <Ban className="w-6 h-6" />, description: 'Embargos de declaração/execução' },
|
||||
{ id: 'recurso-especial', name: 'Recurso Especial', icon: <ScrollText className="w-6 h-6" />, description: 'Recurso ao STJ por violação de lei federal' },
|
||||
{ id: 'agravo', name: 'Agravo', icon: <Hammer className="w-6 h-6" />, description: 'Agravo de instrumento/interno' },
|
||||
{ id: 'outros', name: 'Outros', icon: <MoreHorizontal className="w-6 h-6" />, description: 'Outros tipos de peças jurídicas' },
|
||||
]
|
||||
|
||||
const LEGAL_AREAS: LegalArea[] = [
|
||||
{ id: 'civil', name: 'Civil', icon: <Scale className="w-6 h-6" />, color: '#8b5cf6' },
|
||||
{ id: 'trabalhista', name: 'Trabalhista', icon: <Briefcase className="w-6 h-6" />, color: '#f59e0b' },
|
||||
{ id: 'penal', name: 'Penal', icon: <Gavel className="w-6 h-6" />, color: '#ef4444' },
|
||||
{ id: 'tributario', name: 'Tributário', icon: <Building2 className="w-6 h-6" />, color: '#10b981' },
|
||||
{ id: 'familia', name: 'Família', icon: <Heart className="w-6 h-6" />, color: '#ec4899' },
|
||||
{ id: 'empresarial', name: 'Empresarial', icon: <Home className="w-6 h-6" />, color: '#3b82f6' },
|
||||
{ id: 'consumidor', name: 'Consumidor', icon: <ShoppingCart className="w-6 h-6" />, color: '#f97316' },
|
||||
{ id: 'administrativo', name: 'Administrativo', icon: <Settings className="w-6 h-6" />, color: '#6366f1' },
|
||||
]
|
||||
|
||||
const STEPS = [
|
||||
{ label: 'Tipo', icon: <FileText className="w-4 h-4" /> },
|
||||
{ label: 'Área', icon: <Scale className="w-4 h-4" /> },
|
||||
{ label: 'Detalhes', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
{ label: 'Revisar', icon: <Eye className="w-4 h-4" /> },
|
||||
{ label: 'Resultado', icon: <Sparkles className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
// ─── Animation Variants ─────────────────────────────────────────────
|
||||
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, x: 60 },
|
||||
animate: { opacity: 1, x: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } },
|
||||
exit: { opacity: 0, x: -60, transition: { duration: 0.3 } },
|
||||
} as const
|
||||
|
||||
const cardVariants: Variants = {
|
||||
initial: { opacity: 0, y: 20, scale: 0.95 },
|
||||
animate: (i: number) => ({
|
||||
opacity: 1, y: 0, scale: 1,
|
||||
transition: { delay: i * 0.04, duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
}),
|
||||
hover: { scale: 1.03, transition: { duration: 0.2 } },
|
||||
tap: { scale: 0.97 },
|
||||
}
|
||||
|
||||
const staggerContainer = {
|
||||
animate: { transition: { staggerChildren: 0.04 } },
|
||||
} as const
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────
|
||||
|
||||
export default function NovaPecaPage() {
|
||||
const [step, setStep] = useState(0)
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const [selectedArea, setSelectedArea] = useState<string | null>(null)
|
||||
const [details, setDetails] = useState<FormDetails>({
|
||||
autor: '', reu: '', fatos: '', fundamentos: '', pedidos: '', contexto: '',
|
||||
})
|
||||
const [generatedContent, setGeneratedContent] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [wordCount, setWordCount] = useState(0)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [documentId, setDocumentId] = useState<string | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const editRef = useRef<HTMLTextAreaElement>(null)
|
||||
const searchParams = useSearchParams()
|
||||
const [analysisSource, setAnalysisSource] = useState<{ filename: string; title: string } | null>(null)
|
||||
const [loadingAnalysis, setLoadingAnalysis] = useState(false)
|
||||
|
||||
// Load pre-filled data from analysis
|
||||
useEffect(() => {
|
||||
const analysisId = searchParams.get('from_analysis')
|
||||
if (!analysisId) return
|
||||
|
||||
setLoadingAnalysis(true)
|
||||
fetch(`/api/analise-processo/${analysisId}/peticao`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to load analysis')
|
||||
return res.json()
|
||||
})
|
||||
.then(data => {
|
||||
setSelectedType('peticao-inicial')
|
||||
setSelectedArea(data.extracted.area || 'civil')
|
||||
setDetails({
|
||||
autor: data.extracted.autor || '',
|
||||
reu: data.extracted.reu || '',
|
||||
fatos: data.extracted.fatos || '',
|
||||
fundamentos: data.extracted.fundamentos || '',
|
||||
pedidos: data.extracted.pedidos || '',
|
||||
contexto: `[PARECER JURÍDICO COMPLETO - usar como base para a petição]\n\n${data.parecer}`,
|
||||
})
|
||||
setAnalysisSource({ filename: data.filename, title: data.title })
|
||||
setStep(2) // Go directly to details step
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading analysis:', err)
|
||||
})
|
||||
.finally(() => setLoadingAnalysis(false))
|
||||
}, [searchParams])
|
||||
|
||||
// Word count tracker
|
||||
useEffect(() => {
|
||||
if (generatedContent) {
|
||||
setWordCount(generatedContent.split(/\s+/).filter(Boolean).length)
|
||||
}
|
||||
}, [generatedContent])
|
||||
|
||||
const selectedTypeData = DOCUMENT_TYPES.find(t => t.id === selectedType)
|
||||
const selectedAreaData = LEGAL_AREAS.find(a => a.id === selectedArea)
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setIsGenerating(true)
|
||||
setGeneratedContent('')
|
||||
setStep(4)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/documents/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: selectedType,
|
||||
area: selectedArea,
|
||||
details,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Erro ao gerar documento')
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (!reader) throw new Error('Stream não disponível')
|
||||
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
if (data.done) {
|
||||
setDocumentId(data.documentId)
|
||||
break
|
||||
}
|
||||
if (data.content) {
|
||||
setGeneratedContent(prev => prev + data.content)
|
||||
}
|
||||
if (data.documentId) {
|
||||
setDocumentId(data.documentId)
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Generation error:', error)
|
||||
setGeneratedContent('Erro ao gerar documento. Por favor, tente novamente.')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}, [selectedType, selectedArea, details])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(stripMarkdown(generatedContent))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [generatedContent])
|
||||
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const title = `${selectedTypeData?.name || 'Documento'} - ${selectedAreaData?.name || 'Jurídico'}`
|
||||
await downloadAsWord(title, generatedContent)
|
||||
} catch (err) {
|
||||
console.error('Download error:', err)
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}, [generatedContent, selectedTypeData, selectedAreaData])
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 0: return !!selectedType
|
||||
case 1: return !!selectedArea
|
||||
case 2: return details.fatos.trim().length > 10
|
||||
case 3: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (step < 4 && canProceed()) setStep(step + 1)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (step > 0 && !isGenerating) setStep(step - 1)
|
||||
}
|
||||
|
||||
// ─── Stepper ────────────────────────────────────────────────────
|
||||
|
||||
const renderStepper = () => (
|
||||
<div className="flex items-center justify-center gap-1 mb-8">
|
||||
{STEPS.map((s, i) => {
|
||||
const isActive = i === step
|
||||
const isCompleted = i < step
|
||||
return (
|
||||
<div key={s.label} className="flex items-center">
|
||||
<button
|
||||
onClick={() => { if (i < step && !isGenerating) setStep(i) }}
|
||||
disabled={i > step || isGenerating}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-300
|
||||
${isActive
|
||||
? 'bg-teal-600/30 text-teal-300 border border-teal-500/50 shadow-lg shadow-teal-500/20'
|
||||
: isCompleted
|
||||
? 'bg-teal-900/20 text-teal-400 border border-teal-700/30 cursor-pointer hover:bg-teal-800/20'
|
||||
: 'bg-white/[0.02] text-white/30 border border-white/[0.05]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<span className={isActive ? 'text-teal-300' : ''}>{s.icon}</span>
|
||||
)}
|
||||
<span className="hidden sm:inline">{s.label}</span>
|
||||
</button>
|
||||
{i < STEPS.length - 1 && (
|
||||
<ChevronRight className={`w-4 h-4 mx-1 ${i < step ? 'text-teal-500' : 'text-white/10'}`} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ─── Step 0: Document Type ──────────────────────────────────────
|
||||
|
||||
const renderTypeSelection = () => (
|
||||
<motion.div key="step-type" variants={pageVariants} initial="initial" animate="animate" exit="exit">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold gradient-text mb-2">Que tipo de peça deseja gerar?</h2>
|
||||
<p className="text-white/50">Selecione o tipo de documento jurídico</p>
|
||||
</div>
|
||||
<motion.div
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{DOCUMENT_TYPES.map((type, i) => (
|
||||
<motion.button
|
||||
key={type.id}
|
||||
custom={i}
|
||||
variants={cardVariants}
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onClick={() => { setSelectedType(type.id); setStep(1) }}
|
||||
className={`
|
||||
relative group flex flex-col items-center gap-3 p-5 rounded-2xl text-center transition-all duration-300
|
||||
${selectedType === type.id
|
||||
? 'glass-strong glow-purple border-teal-500/50'
|
||||
: 'glass hover:border-teal-500/30'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`
|
||||
p-3 rounded-xl transition-all duration-300
|
||||
${selectedType === type.id
|
||||
? 'bg-teal-500/30 text-teal-300'
|
||||
: 'bg-white/[0.03] text-white/50 group-hover:text-teal-400 group-hover:bg-teal-500/10'
|
||||
}
|
||||
`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-white/90">{type.name}</p>
|
||||
<p className="text-[11px] text-white/40 mt-1 leading-tight">{type.description}</p>
|
||||
</div>
|
||||
{selectedType === type.id && (
|
||||
<motion.div
|
||||
layoutId="type-check"
|
||||
className="absolute top-2 right-2 w-5 h-5 bg-teal-500 rounded-full flex items-center justify-center"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// ─── Step 1: Legal Area ─────────────────────────────────────────
|
||||
|
||||
const renderAreaSelection = () => (
|
||||
<motion.div key="step-area" variants={pageVariants} initial="initial" animate="animate" exit="exit">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold gradient-text mb-2">Área do Direito</h2>
|
||||
<p className="text-white/50">
|
||||
Gerando: <span className="text-teal-400 font-medium">{selectedTypeData?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<motion.div
|
||||
className="grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-3xl mx-auto"
|
||||
variants={staggerContainer}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{LEGAL_AREAS.map((area, i) => (
|
||||
<motion.button
|
||||
key={area.id}
|
||||
custom={i}
|
||||
variants={cardVariants}
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onClick={() => { setSelectedArea(area.id); setStep(2) }}
|
||||
className={`
|
||||
relative group flex flex-col items-center gap-3 p-6 rounded-2xl transition-all duration-300
|
||||
${selectedArea === area.id
|
||||
? 'glass-strong border-teal-500/50'
|
||||
: 'glass hover:border-teal-500/30'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
boxShadow: selectedArea === area.id ? `0 0 30px ${area.color}30` : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-xl transition-all duration-300"
|
||||
style={{
|
||||
background: selectedArea === area.id ? `${area.color}25` : 'rgba(255,255,255,0.03)',
|
||||
color: selectedArea === area.id ? area.color : 'rgba(255,255,255,0.5)',
|
||||
}}
|
||||
>
|
||||
{area.icon}
|
||||
</div>
|
||||
<p className="font-semibold text-white/90">{area.name}</p>
|
||||
{selectedArea === area.id && (
|
||||
<motion.div
|
||||
layoutId="area-check"
|
||||
className="absolute top-2 right-2 w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ background: area.color }}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// ─── Step 2: Details Form ───────────────────────────────────────
|
||||
|
||||
const renderDetailsForm = () => (
|
||||
<motion.div key="step-details" variants={pageVariants} initial="initial" animate="animate" exit="exit">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold gradient-text mb-2">Detalhes da Peça</h2>
|
||||
<p className="text-white/50">
|
||||
<span className="text-teal-400">{selectedTypeData?.name}</span> · <span className="text-teal-400">{selectedAreaData?.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto space-y-5">
|
||||
{/* Parties */}
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<Users className="w-4 h-4 text-teal-400" />
|
||||
Autor / Requerente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={details.autor}
|
||||
onChange={e => setDetails(d => ({ ...d, autor: e.target.value }))}
|
||||
placeholder="Nome completo do autor"
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<Users className="w-4 h-4 text-red-400" />
|
||||
Réu / Requerido
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={details.reu}
|
||||
onChange={e => setDetails(d => ({ ...d, reu: e.target.value }))}
|
||||
placeholder="Nome completo do réu"
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Facts */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<BookMarked className="w-4 h-4 text-teal-400" />
|
||||
Fatos <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={details.fatos}
|
||||
onChange={e => setDetails(d => ({ ...d, fatos: e.target.value }))}
|
||||
placeholder="Descreva os fatos de forma clara e cronológica. Quanto mais detalhado, melhor será a peça gerada..."
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
|
||||
/>
|
||||
<p className="text-xs text-white/30 mt-1">{details.fatos.length} caracteres · mínimo 10</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Legal Grounds */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<BookOpen className="w-4 h-4 text-teal-400" />
|
||||
Fundamentos Jurídicos
|
||||
</label>
|
||||
<textarea
|
||||
value={details.fundamentos}
|
||||
onChange={e => setDetails(d => ({ ...d, fundamentos: e.target.value }))}
|
||||
placeholder="Artigos de lei, jurisprudência ou teses jurídicas que fundamentam o pedido (opcional - a IA também citará automaticamente)"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Requests */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-teal-400" />
|
||||
Pedidos
|
||||
</label>
|
||||
<textarea
|
||||
value={details.pedidos}
|
||||
onChange={e => setDetails(d => ({ ...d, pedidos: e.target.value }))}
|
||||
placeholder="Descreva os pedidos que deseja formular (tutela antecipada, indenização, etc.)"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Additional Context */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<ClipboardList className="w-4 h-4 text-teal-400" />
|
||||
Contexto Adicional
|
||||
</label>
|
||||
<textarea
|
||||
value={details.contexto}
|
||||
onChange={e => setDetails(d => ({ ...d, contexto: e.target.value }))}
|
||||
placeholder="Qualquer informação adicional relevante (valor da causa, urgência, detalhes processuais...)"
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 rounded-xl glass text-white placeholder-white/30 focus:outline-none focus:border-teal-500/50 focus:ring-1 focus:ring-teal-500/30 transition-all resize-none"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* File Upload */}
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }}>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-white/70 mb-2">
|
||||
<Upload className="w-4 h-4 text-teal-400" />
|
||||
Documentos de Referência
|
||||
</label>
|
||||
<FileUpload compact label="Documentos de Referência" maxFiles={5} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// ─── Step 3: Review ─────────────────────────────────────────────
|
||||
|
||||
const renderReview = () => {
|
||||
const summaryItems = [
|
||||
{ label: 'Tipo de Peça', value: selectedTypeData?.name || '-', icon: <FileText className="w-4 h-4" /> },
|
||||
{ label: 'Área do Direito', value: selectedAreaData?.name || '-', icon: <Scale className="w-4 h-4" /> },
|
||||
{ label: 'Autor', value: details.autor || 'Não informado', icon: <Users className="w-4 h-4" /> },
|
||||
{ label: 'Réu', value: details.reu || 'Não informado', icon: <Users className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div key="step-review" variants={pageVariants} initial="initial" animate="animate" exit="exit">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold gradient-text mb-2">Revisar e Gerar</h2>
|
||||
<p className="text-white/50">Confira os dados antes de gerar a peça</p>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{summaryItems.map((item, i) => (
|
||||
<div key={i} className="glass rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-white/40 text-xs mb-1">
|
||||
<span className="text-teal-400">{item.icon}</span>
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="text-white font-medium text-sm">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Content Preview */}
|
||||
{details.fatos && (
|
||||
<motion.div
|
||||
className="glass rounded-xl p-5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h4 className="text-sm font-medium text-teal-400 mb-2">Fatos</h4>
|
||||
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.fatos}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{details.fundamentos && (
|
||||
<motion.div
|
||||
className="glass rounded-xl p-5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<h4 className="text-sm font-medium text-teal-400 mb-2">Fundamentos Jurídicos</h4>
|
||||
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.fundamentos}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{details.pedidos && (
|
||||
<motion.div
|
||||
className="glass rounded-xl p-5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h4 className="text-sm font-medium text-teal-400 mb-2">Pedidos</h4>
|
||||
<p className="text-white/70 text-sm whitespace-pre-wrap">{details.pedidos}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Credit Notice */}
|
||||
<motion.div
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-teal-500/10 border border-teal-500/20"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<Zap className="w-5 h-5 text-yellow-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-white/80">Esta geração consumirá <span className="text-teal-300 font-bold">1 crédito</span></p>
|
||||
<p className="text-xs text-white/40 mt-0.5">O documento será gerado por IA e pode ser editado depois</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<motion.button
|
||||
onClick={handleGenerate}
|
||||
className="w-full py-4 rounded-2xl bg-gradient-to-r from-teal-600 to-teal-700 hover:from-teal-500 hover:to-teal-600 text-white font-bold text-lg flex items-center justify-center gap-3 transition-all duration-300 glow-purple-strong"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Gerar Peça com IA
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Step 4: Result ─────────────────────────────────────────────
|
||||
|
||||
const renderResult = () => (
|
||||
<motion.div key="step-result" variants={pageVariants} initial="initial" animate="animate" exit="exit">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold gradient-text">
|
||||
{isGenerating ? 'Gerando documento...' : 'Documento Gerado'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-white/50">
|
||||
<span>{selectedTypeData?.name}</span>
|
||||
<span>·</span>
|
||||
<span>{selectedAreaData?.name}</span>
|
||||
{!isGenerating && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{wordCount.toLocaleString('pt-BR')} palavras</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGenerating && generatedContent && (
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl glass hover:bg-teal-500/10 text-sm transition-all"
|
||||
>
|
||||
{copied ? <CheckCircle2 className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||
{copied ? 'Copiado!' : 'Copiar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl glass hover:bg-teal-500/10 text-sm transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{isDownloading ? 'Gerando...' : 'Baixar Word'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditing(!isEditing)
|
||||
if (!isEditing) setTimeout(() => editRef.current?.focus(), 100)
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm transition-all ${
|
||||
isEditing ? 'bg-teal-500/20 text-teal-300 border border-teal-500/30' : 'glass hover:bg-teal-500/10'
|
||||
}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
{isEditing ? 'Visualizar' : 'Editar'}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<motion.div
|
||||
className="glass-strong rounded-2xl p-6 sm:p-8 min-h-[400px] relative overflow-hidden"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{isGenerating && (
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-1 bg-gradient-to-r from-teal-500 via-pink-500 to-teal-500 rounded-full"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: 30, ease: 'linear' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={editRef}
|
||||
value={generatedContent}
|
||||
onChange={e => setGeneratedContent(e.target.value)}
|
||||
className="w-full h-[600px] bg-transparent text-white/90 text-sm leading-relaxed font-mono resize-none focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="prose prose-invert prose-sm max-w-none prose-headings:text-white prose-headings:font-bold prose-strong:text-white prose-code:text-teal-300 prose-blockquote:border-teal-500/30 prose-blockquote:text-zinc-400 prose-a:text-teal-400 prose-li:text-gray-300 prose-li:marker:text-teal-500 selection:bg-teal-500/30"
|
||||
>
|
||||
{generatedContent ? (
|
||||
<ReactMarkdown>{generatedContent}</ReactMarkdown>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-4">
|
||||
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
|
||||
<p className="text-white/40">Gerando sua peça jurídica...</p>
|
||||
<p className="text-xs text-white/20">Isso pode levar até 1 minuto</p>
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && generatedContent && (
|
||||
<motion.span
|
||||
className="inline-block w-2 h-5 bg-teal-400 ml-0.5"
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* New Document Button */}
|
||||
{!isGenerating && generatedContent && (
|
||||
<motion.div
|
||||
className="flex justify-center mt-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0)
|
||||
setSelectedType(null)
|
||||
setSelectedArea(null)
|
||||
setDetails({ autor: '', reu: '', fatos: '', fundamentos: '', pedidos: '', contexto: '' })
|
||||
setGeneratedContent('')
|
||||
setDocumentId(null)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl glass hover:bg-teal-500/10 text-white/70 hover:text-white transition-all"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Gerar Nova Peça
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// ─── Main Render ────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0f1a] bg-grid">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 bg-radial-purple pointer-events-none" />
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="p-2 rounded-xl glass hover:bg-teal-500/10 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-white/60" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-teal-400" />
|
||||
Nova Peça Jurídica
|
||||
</h1>
|
||||
<p className="text-xs text-white/40">Gerador de documentos com IA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Source Banner */}
|
||||
{analysisSource && (
|
||||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-teal-500/20 bg-teal-500/10 px-5 py-3">
|
||||
<FileText className="h-5 w-5 text-teal-400 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-teal-300">
|
||||
📋 Petição baseada no parecer de: <span className="text-white">{analysisSource.filename}</span>
|
||||
</p>
|
||||
<p className="text-xs text-teal-400/60 mt-0.5">Os campos foram pré-preenchidos com dados extraídos da análise</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Analysis */}
|
||||
{loadingAnalysis && (
|
||||
<div className="mb-4 flex items-center justify-center gap-3 rounded-xl border border-white/5 bg-white/[0.02] px-5 py-6">
|
||||
<Loader2 className="h-5 w-5 text-teal-400 animate-spin" />
|
||||
<p className="text-sm text-white/60">Carregando dados do parecer...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stepper */}
|
||||
{renderStepper()}
|
||||
|
||||
{/* Step Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 0 && renderTypeSelection()}
|
||||
{step === 1 && renderAreaSelection()}
|
||||
{step === 2 && renderDetailsForm()}
|
||||
{step === 3 && renderReview()}
|
||||
{step === 4 && renderResult()}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Navigation Buttons (steps 1-2) */}
|
||||
{step > 0 && step < 4 && (
|
||||
<motion.div
|
||||
className="flex items-center justify-between max-w-3xl mx-auto mt-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl glass hover:bg-teal-500/10 text-white/60 hover:text-white transition-all"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</button>
|
||||
{step < 3 && (
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canProceed()}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2.5 rounded-xl font-medium transition-all
|
||||
${canProceed()
|
||||
? 'bg-teal-600 hover:bg-teal-500 text-white glow-purple'
|
||||
: 'bg-white/5 text-white/20 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
Próximo
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
423
src/app/dashboard/page.tsx
Normal file
423
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import Link from 'next/link'
|
||||
import { Suspense } from 'react'
|
||||
import { UpgradeSuccessBanner, UpgradePrompt } from './UpgradeBanner'
|
||||
import {
|
||||
FileText,
|
||||
Coins,
|
||||
LetterText,
|
||||
Scale,
|
||||
FilePlus,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
PETICAO_INICIAL: 'Petição Inicial',
|
||||
CONTESTACAO: 'Contestação',
|
||||
APELACAO: 'Apelação',
|
||||
RECURSO: 'Recurso',
|
||||
CONTRATO: 'Contrato',
|
||||
PARECER: 'Parecer',
|
||||
IMPUGNACAO: 'Impugnação',
|
||||
HABEAS_CORPUS: 'Habeas Corpus',
|
||||
MANDADO_SEGURANCA: 'Mandado de Segurança',
|
||||
OUTROS: 'Outros',
|
||||
}
|
||||
|
||||
const areaLabels: Record<string, string> = {
|
||||
CIVIL: 'Civil',
|
||||
TRABALHISTA: 'Trabalhista',
|
||||
PENAL: 'Penal',
|
||||
TRIBUTARIO: 'Tributário',
|
||||
FAMILIA: 'Família',
|
||||
EMPRESARIAL: 'Empresarial',
|
||||
CONSUMIDOR: 'Consumidor',
|
||||
ADMINISTRATIVO: 'Administrativo',
|
||||
}
|
||||
|
||||
const areaColors: Record<string, string> = {
|
||||
CIVIL: 'bg-blue-500/15 text-blue-400',
|
||||
TRABALHISTA: 'bg-orange-500/15 text-orange-400',
|
||||
PENAL: 'bg-red-500/15 text-red-400',
|
||||
TRIBUTARIO: 'bg-green-500/15 text-green-400',
|
||||
FAMILIA: 'bg-pink-500/15 text-pink-400',
|
||||
EMPRESARIAL: 'bg-cyan-500/15 text-cyan-400',
|
||||
CONSUMIDOR: 'bg-yellow-500/15 text-yellow-400',
|
||||
ADMINISTRATIVO: 'bg-teal-500/15 text-teal-400',
|
||||
}
|
||||
|
||||
const statusIndicator: Record<string, string> = {
|
||||
COMPLETED: 'bg-emerald-500',
|
||||
GENERATING: 'bg-amber-500 animate-pulse',
|
||||
ERROR: 'bg-red-500',
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return n.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch all stats in parallel
|
||||
const [
|
||||
documentCount,
|
||||
totalWords,
|
||||
jurisprudenciaCount,
|
||||
recentDocuments,
|
||||
user,
|
||||
] = await Promise.all([
|
||||
prisma.document.count({ where: { userId } }),
|
||||
prisma.document.aggregate({
|
||||
where: { userId },
|
||||
_sum: { wordCount: true },
|
||||
}),
|
||||
prisma.usageLog.count({
|
||||
where: { userId, type: 'JURISPRUDENCIA' },
|
||||
}),
|
||||
prisma.document.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true, plan: true, name: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const credits = user?.credits ?? 0
|
||||
const plan = user?.plan ?? 'FREE'
|
||||
const firstName = (user?.name ?? session.user.name).split(' ')[0]
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Peças criadas',
|
||||
value: formatNumber(documentCount),
|
||||
icon: FileText,
|
||||
color: 'from-teal-600 to-teal-800',
|
||||
iconBg: 'bg-teal-500/20',
|
||||
iconColor: 'text-teal-400',
|
||||
},
|
||||
{
|
||||
label: 'Créditos restantes',
|
||||
value: formatNumber(credits),
|
||||
icon: Coins,
|
||||
color: 'from-amber-600 to-amber-800',
|
||||
iconBg: 'bg-amber-500/20',
|
||||
iconColor: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
label: 'Palavras geradas',
|
||||
value: formatNumber(totalWords._sum.wordCount ?? 0),
|
||||
icon: LetterText,
|
||||
color: 'from-cyan-600 to-cyan-800',
|
||||
iconBg: 'bg-cyan-500/20',
|
||||
iconColor: 'text-cyan-400',
|
||||
},
|
||||
{
|
||||
label: 'Jurisprudências consultadas',
|
||||
value: formatNumber(jurisprudenciaCount),
|
||||
icon: Scale,
|
||||
color: 'from-emerald-600 to-emerald-800',
|
||||
iconBg: 'bg-emerald-500/20',
|
||||
iconColor: 'text-emerald-400',
|
||||
},
|
||||
]
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
label: 'Nova Petição',
|
||||
href: '/dashboard/nova-peca?type=PETICAO_INICIAL',
|
||||
icon: FilePlus,
|
||||
desc: 'Petição inicial com IA',
|
||||
gradient: 'from-teal-600 to-indigo-700',
|
||||
},
|
||||
{
|
||||
label: 'Nova Contestação',
|
||||
href: '/dashboard/nova-peca?type=CONTESTACAO',
|
||||
icon: ShieldAlert,
|
||||
desc: 'Contestação fundamentada',
|
||||
gradient: 'from-rose-600 to-pink-700',
|
||||
},
|
||||
{
|
||||
label: 'Chat IA',
|
||||
href: '/dashboard/chat',
|
||||
icon: MessageSquare,
|
||||
desc: 'Tire dúvidas com IA',
|
||||
gradient: 'from-cyan-600 to-teal-700',
|
||||
},
|
||||
{
|
||||
label: 'Buscar Jurisprudência',
|
||||
href: '/dashboard/jurisprudencia',
|
||||
icon: Search,
|
||||
desc: 'Pesquise decisões',
|
||||
gradient: 'from-amber-600 to-orange-700',
|
||||
},
|
||||
]
|
||||
|
||||
const greeting = (() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Bom dia'
|
||||
if (h < 18) return 'Boa tarde'
|
||||
return 'Boa noite'
|
||||
})()
|
||||
|
||||
const planBadge: Record<string, { label: string; style: string }> = {
|
||||
FREE: { label: 'Free', style: 'bg-zinc-700 text-zinc-300' },
|
||||
STARTER: { label: 'Starter', style: 'bg-blue-600/20 text-blue-400 border border-blue-500/30' },
|
||||
PRO: { label: 'Pro', style: 'bg-teal-600/20 text-teal-400 border border-teal-500/30' },
|
||||
ENTERPRISE: { label: 'Enterprise', style: 'bg-amber-600/20 text-amber-400 border border-amber-500/30' },
|
||||
}
|
||||
|
||||
const badge = planBadge[plan] || planBadge.FREE
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeSuccessBanner />
|
||||
</Suspense>
|
||||
<UpgradePrompt plan={plan} credits={credits} />
|
||||
{/* Welcome card */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-teal-900/30 via-[#111b27] to-[#0f1620] p-6 sm:p-8">
|
||||
<div className="absolute -right-20 -top-20 h-60 w-60 rounded-full bg-teal-600/10 blur-3xl" />
|
||||
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-indigo-600/10 blur-3xl" />
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
{greeting}, {firstName}! <span className="inline-block animate-[wave_2s_ease-in-out_infinite]">👋</span>
|
||||
</h1>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wider ${badge.style}`}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 max-w-xl text-sm text-zinc-400 sm:text-base">
|
||||
Aqui está um resumo da sua atividade. Crie peças jurídicas com IA, pesquise jurisprudência e muito mais.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-[#111b27] p-5 transition-all duration-200 hover:border-white/10 hover:bg-[#162030]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`rounded-xl p-2.5 ${stat.iconBg}`}>
|
||||
<Icon className={`h-5 w-5 ${stat.iconColor}`} />
|
||||
</div>
|
||||
<TrendingUp className="h-4 w-4 text-zinc-600 transition-colors group-hover:text-zinc-400" />
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold tracking-tight text-white">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-zinc-500">{stat.label}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Ações rápidas</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Link
|
||||
key={action.label}
|
||||
href={action.href}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-[#111b27] p-5 transition-all duration-200 hover:border-teal-500/20 hover:shadow-lg hover:shadow-teal-900/10"
|
||||
>
|
||||
<div
|
||||
className={`inline-flex rounded-xl bg-gradient-to-br ${action.gradient} p-2.5`}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-semibold text-white group-hover:text-teal-300">
|
||||
{action.label}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{action.desc}</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent documents */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Documentos recentes</h2>
|
||||
<Link
|
||||
href="/dashboard/minhas-pecas"
|
||||
className="text-sm text-teal-400 transition-colors hover:text-teal-300"
|
||||
>
|
||||
Ver todos →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentDocuments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-white/10 bg-[#111b27] py-16 text-center">
|
||||
<FileText className="h-12 w-12 text-zinc-700" />
|
||||
<h3 className="mt-4 text-base font-medium text-zinc-400">
|
||||
Nenhum documento ainda
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Crie sua primeira peça jurídica com IA
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/nova-peca"
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-xl bg-teal-600 px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-teal-500"
|
||||
>
|
||||
<FilePlus className="h-4 w-4" />
|
||||
Nova Peça
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-white/5 bg-[#111b27]">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">
|
||||
<th className="px-5 py-3.5">Título</th>
|
||||
<th className="px-5 py-3.5">Tipo</th>
|
||||
<th className="px-5 py-3.5">Área</th>
|
||||
<th className="px-5 py-3.5">Palavras</th>
|
||||
<th className="px-5 py-3.5">Status</th>
|
||||
<th className="px-5 py-3.5">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{recentDocuments.map((doc) => (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className="transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/minhas-pecas/${doc.id}`}
|
||||
className="text-sm font-medium text-white hover:text-teal-400"
|
||||
>
|
||||
{doc.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-sm text-zinc-400">
|
||||
{typeLabels[doc.type] || doc.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span
|
||||
className={`inline-block rounded-md px-2 py-0.5 text-xs font-medium ${
|
||||
areaColors[doc.area] || 'bg-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{areaLabels[doc.area] || doc.area}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-sm text-zinc-400">
|
||||
{doc.wordCount.toLocaleString('pt-BR')}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
statusIndicator[doc.status] || 'bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-zinc-400">
|
||||
{doc.status === 'COMPLETED'
|
||||
? 'Concluído'
|
||||
: doc.status === 'GENERATING'
|
||||
? 'Gerando...'
|
||||
: 'Erro'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatDate(doc.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="divide-y divide-white/5 sm:hidden">
|
||||
{recentDocuments.map((doc) => (
|
||||
<Link
|
||||
key={doc.id}
|
||||
href={`/dashboard/minhas-pecas/${doc.id}`}
|
||||
className="block p-4 transition-colors hover:bg-white/[0.02]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{doc.title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{typeLabels[doc.type] || doc.type} · {doc.wordCount.toLocaleString('pt-BR')} palavras
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`mt-0.5 h-2 w-2 flex-shrink-0 rounded-full ${
|
||||
statusIndicator[doc.status] || 'bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${
|
||||
areaColors[doc.area] || 'bg-zinc-700 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{areaLabels[doc.area] || doc.area}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{formatDate(doc.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
479
src/app/dashboard/prazos/page.tsx
Normal file
479
src/app/dashboard/prazos/page.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Clock,
|
||||
Plus,
|
||||
X,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Filter,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Building2,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Prazo {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
processNumber: string | null
|
||||
court: string | null
|
||||
deadline: string
|
||||
alertDays: number
|
||||
status: 'PENDENTE' | 'CONCLUIDO' | 'VENCIDO' | 'CANCELADO'
|
||||
priority: 'ALTA' | 'MEDIA' | 'BAIXA'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const priorityConfig = {
|
||||
ALTA: { label: 'Alta', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30', dot: 'bg-red-500' },
|
||||
MEDIA: { label: 'Média', color: 'text-yellow-400', bg: 'bg-yellow-500/15 border-yellow-500/30', dot: 'bg-yellow-500' },
|
||||
BAIXA: { label: 'Baixa', color: 'text-green-400', bg: 'bg-green-500/15 border-green-500/30', dot: 'bg-green-500' },
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
PENDENTE: { label: 'Pendente', color: 'text-blue-400', bg: 'bg-blue-500/15 border-blue-500/30', icon: Clock },
|
||||
CONCLUIDO: { label: 'Concluído', color: 'text-green-400', bg: 'bg-green-500/15 border-green-500/30', icon: CheckCircle2 },
|
||||
VENCIDO: { label: 'Vencido', color: 'text-red-400', bg: 'bg-red-500/15 border-red-500/30', icon: AlertTriangle },
|
||||
CANCELADO: { label: 'Cancelado', color: 'text-zinc-400', bg: 'bg-zinc-500/15 border-zinc-500/30', icon: XCircle },
|
||||
}
|
||||
|
||||
export default function PrazosPage() {
|
||||
const [prazos, setPrazos] = useState<Prazo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingPrazo, setEditingPrazo] = useState<Prazo | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<string>('')
|
||||
const [filterPriority, setFilterPriority] = useState<string>('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
processNumber: '',
|
||||
court: '',
|
||||
deadline: '',
|
||||
alertDays: 3,
|
||||
priority: 'MEDIA' as 'ALTA' | 'MEDIA' | 'BAIXA',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrazos()
|
||||
}, [filterStatus, filterPriority])
|
||||
|
||||
async function fetchPrazos() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterPriority) params.set('priority', filterPriority)
|
||||
const res = await fetch(`/api/prazos?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPrazos(data.prazos || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load prazos:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openNewModal() {
|
||||
setEditingPrazo(null)
|
||||
setForm({ title: '', description: '', processNumber: '', court: '', deadline: '', alertDays: 3, priority: 'MEDIA' })
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function openEditModal(prazo: Prazo) {
|
||||
setEditingPrazo(prazo)
|
||||
setForm({
|
||||
title: prazo.title,
|
||||
description: prazo.description || '',
|
||||
processNumber: prazo.processNumber || '',
|
||||
court: prazo.court || '',
|
||||
deadline: prazo.deadline.split('T')[0],
|
||||
alertDays: prazo.alertDays,
|
||||
priority: prazo.priority,
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.title || !form.deadline) return
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingPrazo) {
|
||||
await fetch(`/api/prazos/${editingPrazo.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
} else {
|
||||
await fetch('/api/prazos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
setShowModal(false)
|
||||
fetchPrazos()
|
||||
} catch (e) {
|
||||
console.error('Failed to save:', e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/prazos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
fetchPrazos()
|
||||
} catch (e) {
|
||||
console.error('Failed to update status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Tem certeza que deseja excluir este prazo?')) return
|
||||
try {
|
||||
await fetch(`/api/prazos/${id}`, { method: 'DELETE' })
|
||||
fetchPrazos()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function getDaysUntil(deadline: string) {
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
const dl = new Date(deadline)
|
||||
dl.setHours(0, 0, 0, 0)
|
||||
return Math.ceil((dl.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: prazos.length,
|
||||
pendentes: prazos.filter(p => p.status === 'PENDENTE').length,
|
||||
vencidos: prazos.filter(p => p.status === 'VENCIDO').length,
|
||||
concluidos: prazos.filter(p => p.status === 'CONCLUIDO').length,
|
||||
}
|
||||
|
||||
// Auto-mark overdue
|
||||
const processedPrazos = prazos.map(p => {
|
||||
if (p.status === 'PENDENTE' && getDaysUntil(p.deadline) < 0) {
|
||||
return { ...p, status: 'VENCIDO' as const }
|
||||
}
|
||||
return p
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-600/20">
|
||||
<Clock className="h-5 w-5 text-teal-400" />
|
||||
</div>
|
||||
Gestão de Prazos
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Gerencie seus prazos processuais e compromissos</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openNewModal}
|
||||
className="flex items-center gap-2 rounded-xl bg-teal-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-lg hover:shadow-teal-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo Prazo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-white', bg: 'bg-white/5 border-white/10' },
|
||||
{ label: 'Pendentes', value: stats.pendentes, color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20' },
|
||||
{ label: 'Vencidos', value: stats.vencidos, color: 'text-red-400', bg: 'bg-red-500/10 border-red-500/20' },
|
||||
{ label: 'Concluídos', value: stats.concluidos, color: 'text-green-400', bg: 'bg-green-500/10 border-green-500/20' },
|
||||
].map(s => (
|
||||
<div key={s.label} className={`rounded-xl border ${s.bg} p-4`}>
|
||||
<p className="text-xs font-medium text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<Filter className="h-4 w-4 text-zinc-500" />
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-teal-500/40"
|
||||
>
|
||||
<option value="">Todos os status</option>
|
||||
<option value="PENDENTE">Pendente</option>
|
||||
<option value="CONCLUIDO">Concluído</option>
|
||||
<option value="VENCIDO">Vencido</option>
|
||||
<option value="CANCELADO">Cancelado</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={e => setFilterPriority(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-teal-500/40"
|
||||
>
|
||||
<option value="">Todas as prioridades</option>
|
||||
<option value="ALTA">Alta</option>
|
||||
<option value="MEDIA">Média</option>
|
||||
<option value="BAIXA">Baixa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Prazos List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : processedPrazos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<Calendar className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhum prazo encontrado</p>
|
||||
<button onClick={openNewModal} className="mt-4 text-sm text-teal-400 hover:text-teal-300">
|
||||
Adicionar primeiro prazo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{processedPrazos.map(prazo => {
|
||||
const days = getDaysUntil(prazo.deadline)
|
||||
const isUrgent = prazo.status === 'PENDENTE' && days >= 0 && days <= 3
|
||||
const isOverdue = days < 0 && prazo.status === 'PENDENTE'
|
||||
const prio = priorityConfig[prazo.priority]
|
||||
const stat = statusConfig[prazo.status]
|
||||
const StatusIcon = stat.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prazo.id}
|
||||
className={`group relative rounded-xl border bg-white/[0.02] p-4 transition-all hover:bg-white/[0.04] ${
|
||||
isUrgent || isOverdue ? 'border-red-500/30 shadow-[0_0_15px_rgba(239,68,68,0.1)]' : 'border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Priority indicator */}
|
||||
<div className={`mt-1 h-3 w-3 rounded-full ${prio.dot} flex-shrink-0`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{prazo.title}</h3>
|
||||
{prazo.description && (
|
||||
<p className="mt-0.5 text-sm text-zinc-500 line-clamp-1">{prazo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${prio.bg} ${prio.color}`}>
|
||||
{prio.label}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${stat.bg} ${stat.color}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatDate(prazo.deadline)}
|
||||
</span>
|
||||
{prazo.processNumber && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{prazo.processNumber}
|
||||
</span>
|
||||
)}
|
||||
{prazo.court && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
{prazo.court}
|
||||
</span>
|
||||
)}
|
||||
{prazo.status === 'PENDENTE' && (
|
||||
<span className={`font-semibold ${isOverdue ? 'text-red-400' : isUrgent ? 'text-red-400 animate-pulse' : days <= 7 ? 'text-yellow-400' : 'text-zinc-400'}`}>
|
||||
{isOverdue
|
||||
? `Vencido há ${Math.abs(days)} dia${Math.abs(days) !== 1 ? 's' : ''}`
|
||||
: days === 0
|
||||
? 'Vence hoje!'
|
||||
: days === 1
|
||||
? 'Vence amanhã'
|
||||
: `${days} dias restantes`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{prazo.status === 'PENDENTE' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange(prazo.id, 'CONCLUIDO')}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-green-500/10 hover:text-green-400"
|
||||
title="Marcar como concluído"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(prazo)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-white/10 hover:text-zinc-300"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(prazo.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-red-500/10 hover:text-red-400"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-lg rounded-2xl border border-white/10 bg-[#0f1620] p-6 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{editingPrazo ? 'Editar Prazo' : 'Novo Prazo'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)} className="rounded-lg p-1.5 text-zinc-400 hover:bg-white/5 hover:text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Título *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
placeholder="Ex: Contestação - Processo 1234567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Descrição</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
rows={2}
|
||||
placeholder="Detalhes adicionais..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Nº do Processo</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.processNumber}
|
||||
onChange={e => setForm(f => ({ ...f, processNumber: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
placeholder="0000000-00.0000.0.00.0000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Tribunal/Vara</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.court}
|
||||
onChange={e => setForm(f => ({ ...f, court: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
placeholder="Ex: 1ª Vara Cível"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Prazo *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.deadline}
|
||||
onChange={e => setForm(f => ({ ...f, deadline: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Alerta (dias)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.alertDays}
|
||||
onChange={e => setForm(f => ({ ...f, alertDays: parseInt(e.target.value) || 3 }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
min={1}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Prioridade</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setForm(f => ({ ...f, priority: e.target.value as 'ALTA' | 'MEDIA' | 'BAIXA' }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white outline-none focus:border-teal-500/40"
|
||||
>
|
||||
<option value="ALTA">Alta</option>
|
||||
<option value="MEDIA">Média</option>
|
||||
<option value="BAIXA">Baixa</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:bg-white/5"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!form.title || !form.deadline || saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-500 disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editingPrazo ? 'Salvar' : 'Criar Prazo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
282
src/app/dashboard/publicacoes/novo/page.tsx
Normal file
282
src/app/dashboard/publicacoes/novo/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Building2,
|
||||
MapPin,
|
||||
Users,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
const TRIBUNAIS = [
|
||||
{ value: 'STF', label: 'STF - Supremo Tribunal Federal' },
|
||||
{ value: 'STJ', label: 'STJ - Superior Tribunal de Justiça' },
|
||||
{ value: 'TST', label: 'TST - Tribunal Superior do Trabalho' },
|
||||
{ value: 'TSE', label: 'TSE - Tribunal Superior Eleitoral' },
|
||||
{ value: 'STM', label: 'STM - Superior Tribunal Militar' },
|
||||
{ value: 'TRF1', label: 'TRF1 - 1ª Região' },
|
||||
{ value: 'TRF2', label: 'TRF2 - 2ª Região (RJ, ES)' },
|
||||
{ value: 'TRF3', label: 'TRF3 - 3ª Região (SP, MS)' },
|
||||
{ value: 'TRF4', label: 'TRF4 - 4ª Região (RS, PR, SC)' },
|
||||
{ value: 'TRF5', label: 'TRF5 - 5ª Região (PE, AL, CE, PB, RN, SE)' },
|
||||
{ value: 'TRF6', label: 'TRF6 - 6ª Região (MG)' },
|
||||
{ value: 'TJSP', label: 'TJSP - São Paulo' },
|
||||
{ value: 'TJRJ', label: 'TJRJ - Rio de Janeiro' },
|
||||
{ value: 'TJMG', label: 'TJMG - Minas Gerais' },
|
||||
{ value: 'TJRS', label: 'TJRS - Rio Grande do Sul' },
|
||||
{ value: 'TJPR', label: 'TJPR - Paraná' },
|
||||
{ value: 'TJSC', label: 'TJSC - Santa Catarina' },
|
||||
{ value: 'TJBA', label: 'TJBA - Bahia' },
|
||||
{ value: 'TJPE', label: 'TJPE - Pernambuco' },
|
||||
{ value: 'TJCE', label: 'TJCE - Ceará' },
|
||||
{ value: 'TJDF', label: 'TJDF - Distrito Federal' },
|
||||
{ value: 'TJGO', label: 'TJGO - Goiás' },
|
||||
{ value: 'TJMA', label: 'TJMA - Maranhão' },
|
||||
{ value: 'TJPA', label: 'TJPA - Pará' },
|
||||
{ value: 'TJMT', label: 'TJMT - Mato Grosso' },
|
||||
{ value: 'TJMS', label: 'TJMS - Mato Grosso do Sul' },
|
||||
{ value: 'TJES', label: 'TJES - Espírito Santo' },
|
||||
{ value: 'TJAL', label: 'TJAL - Alagoas' },
|
||||
{ value: 'TJAM', label: 'TJAM - Amazonas' },
|
||||
{ value: 'TJPB', label: 'TJPB - Paraíba' },
|
||||
{ value: 'TJPI', label: 'TJPI - Piauí' },
|
||||
{ value: 'TJRN', label: 'TJRN - Rio Grande do Norte' },
|
||||
{ value: 'TJSE', label: 'TJSE - Sergipe' },
|
||||
{ value: 'TJTO', label: 'TJTO - Tocantins' },
|
||||
{ value: 'TJAC', label: 'TJAC - Acre' },
|
||||
{ value: 'TJAP', label: 'TJAP - Amapá' },
|
||||
{ value: 'TJRO', label: 'TJRO - Rondônia' },
|
||||
{ value: 'TJRR', label: 'TJRR - Roraima' },
|
||||
]
|
||||
|
||||
export default function NovoProcessoPage() {
|
||||
const router = useRouter()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
numeroProcesso: '',
|
||||
tribunal: '',
|
||||
vara: '',
|
||||
comarca: '',
|
||||
parteAutora: '',
|
||||
parteRe: '',
|
||||
})
|
||||
|
||||
// Máscara CNJ: 0000000-00.0000.0.00.0000
|
||||
function formatCNJ(value: string): string {
|
||||
const digits = value.replace(/\D/g, '').slice(0, 20)
|
||||
|
||||
if (digits.length <= 7) return digits
|
||||
if (digits.length <= 9) return `${digits.slice(0, 7)}-${digits.slice(7)}`
|
||||
if (digits.length <= 13) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9)}`
|
||||
if (digits.length <= 14) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13)}`
|
||||
if (digits.length <= 16) return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13, 14)}.${digits.slice(14)}`
|
||||
return `${digits.slice(0, 7)}-${digits.slice(7, 9)}.${digits.slice(9, 13)}.${digits.slice(13, 14)}.${digits.slice(14, 16)}.${digits.slice(16)}`
|
||||
}
|
||||
|
||||
function handleNumeroChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const formatted = formatCNJ(e.target.value)
|
||||
setForm(f => ({ ...f, numeroProcesso: formatted }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!form.numeroProcesso || !form.tribunal) {
|
||||
setError('Número do processo e tribunal são obrigatórios')
|
||||
return
|
||||
}
|
||||
|
||||
// Valida formato CNJ
|
||||
const cnjRegex = /^\d{7}-\d{2}\.\d{4}\.\d{1}\.\d{2}\.\d{4}$/
|
||||
if (!cnjRegex.test(form.numeroProcesso)) {
|
||||
setError('Número do processo inválido. Complete o formato CNJ: 0000000-00.0000.0.00.0000')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/processos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Erro ao cadastrar processo')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard/publicacoes')
|
||||
} catch (e) {
|
||||
setError('Erro de conexão')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Voltar
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600/20">
|
||||
<FileText className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
Adicionar Processo para Monitorar
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
Cadastre um processo para acompanhar publicações nos Diários Oficiais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Número do Processo */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
Dados do Processo
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
Número do Processo (CNJ) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.numeroProcesso}
|
||||
onChange={handleNumeroChange}
|
||||
placeholder="0000000-00.0000.0.00.0000"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 font-mono text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
Formato: NNNNNNN-DD.AAAA.J.TR.OOOO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
||||
Tribunal *
|
||||
</label>
|
||||
<select
|
||||
value={form.tribunal}
|
||||
onChange={e => setForm(f => ({ ...f, tribunal: e.target.value }))}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-white outline-none focus:border-indigo-500/40 [&>option]:bg-zinc-900"
|
||||
>
|
||||
<option value="">Selecione o tribunal</option>
|
||||
{TRIBUNAIS.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Localização */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
|
||||
<MapPin className="h-4 w-4 text-green-400" />
|
||||
Localização (opcional)
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Vara</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.vara}
|
||||
onChange={e => setForm(f => ({ ...f, vara: e.target.value }))}
|
||||
placeholder="Ex: 1ª Vara Cível"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Comarca</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.comarca}
|
||||
onChange={e => setForm(f => ({ ...f, comarca: e.target.value }))}
|
||||
placeholder="Ex: São Paulo"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Partes */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.02] p-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2 mb-4">
|
||||
<Users className="h-4 w-4 text-purple-400" />
|
||||
Partes (opcional)
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Parte Autora</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.parteAutora}
|
||||
onChange={e => setForm(f => ({ ...f, parteAutora: e.target.value }))}
|
||||
placeholder="Nome do autor"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1">Parte Ré</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.parteRe}
|
||||
onChange={e => setForm(f => ({ ...f, parteRe: e.target.value }))}
|
||||
placeholder="Nome do réu"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-white outline-none focus:border-indigo-500/40 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="rounded-xl border border-white/10 px-6 py-2.5 text-sm font-medium text-zinc-400 hover:bg-white/5"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Adicionar Processo
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
469
src/app/dashboard/publicacoes/page.tsx
Normal file
469
src/app/dashboard/publicacoes/page.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Newspaper,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Calendar,
|
||||
Building2,
|
||||
FileText,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Processo {
|
||||
id: string
|
||||
numeroProcesso: string
|
||||
tribunal: string
|
||||
vara: string | null
|
||||
comarca: string | null
|
||||
parteAutora: string | null
|
||||
parteRe: string | null
|
||||
status: 'ATIVO' | 'ARQUIVADO' | 'SUSPENSO'
|
||||
publicacoesNaoLidas: number
|
||||
ultimaPublicacao: Publicacao | null
|
||||
}
|
||||
|
||||
interface Publicacao {
|
||||
id: string
|
||||
processoId: string
|
||||
dataPublicacao: string
|
||||
diario: string
|
||||
conteudo: string
|
||||
tipo: 'INTIMACAO' | 'CITACAO' | 'SENTENCA' | 'DESPACHO' | 'ACORDAO' | 'OUTROS'
|
||||
prazoCalculado: string | null
|
||||
prazoTipo: string | null
|
||||
visualizado: boolean
|
||||
processo?: {
|
||||
numeroProcesso: string
|
||||
tribunal: string
|
||||
parteAutora: string | null
|
||||
parteRe: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const tipoConfig = {
|
||||
INTIMACAO: { label: 'Intimação', bg: 'bg-blue-500/15 border-blue-500/30', color: 'text-blue-400' },
|
||||
CITACAO: { label: 'Citação', bg: 'bg-purple-500/15 border-purple-500/30', color: 'text-purple-400' },
|
||||
SENTENCA: { label: 'Sentença', bg: 'bg-red-500/15 border-red-500/30', color: 'text-red-400' },
|
||||
DESPACHO: { label: 'Despacho', bg: 'bg-yellow-500/15 border-yellow-500/30', color: 'text-yellow-400' },
|
||||
ACORDAO: { label: 'Acórdão', bg: 'bg-orange-500/15 border-orange-500/30', color: 'text-orange-400' },
|
||||
OUTROS: { label: 'Outros', bg: 'bg-zinc-500/15 border-zinc-500/30', color: 'text-zinc-400' },
|
||||
}
|
||||
|
||||
export default function PublicacoesPage() {
|
||||
const router = useRouter()
|
||||
const [processos, setProcessos] = useState<Processo[]>([])
|
||||
const [publicacoes, setPublicacoes] = useState<Publicacao[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [buscando, setBuscando] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
// Filtros
|
||||
const [filterTipo, setFilterTipo] = useState<string>('')
|
||||
const [filterVisualizado, setFilterVisualizado] = useState<string>('')
|
||||
const [filterProcesso, setFilterProcesso] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [filterTipo, filterVisualizado, filterProcesso])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Busca processos e publicações em paralelo
|
||||
const [processosRes, publicacoesRes] = await Promise.all([
|
||||
fetch('/api/processos'),
|
||||
fetchPublicacoes(),
|
||||
])
|
||||
|
||||
if (processosRes.ok) {
|
||||
const data = await processosRes.json()
|
||||
setProcessos(data.processos || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublicacoes() {
|
||||
const params = new URLSearchParams()
|
||||
if (filterTipo) params.set('tipo', filterTipo)
|
||||
if (filterVisualizado) params.set('visualizado', filterVisualizado)
|
||||
if (filterProcesso) params.set('processoId', filterProcesso)
|
||||
|
||||
const res = await fetch(`/api/publicacoes?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPublicacoes(data.publicacoes || [])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function buscarNovasPublicacoes() {
|
||||
setBuscando(true)
|
||||
try {
|
||||
const res = await fetch('/api/publicacoes/buscar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to search:', e)
|
||||
} finally {
|
||||
setBuscando(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function marcarComoLida(id: string) {
|
||||
try {
|
||||
await fetch(`/api/publicacoes/${id}/visualizar`, { method: 'PATCH' })
|
||||
setPublicacoes(pubs => pubs.map(p => p.id === id ? { ...p, visualizado: true } : p))
|
||||
} catch (e) {
|
||||
console.error('Failed to mark as read:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProcesso(id: string) {
|
||||
if (!confirm('Tem certeza que deseja remover este processo do monitoramento?')) return
|
||||
try {
|
||||
await fetch(`/api/processos/${id}`, { method: 'DELETE' })
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getDaysUntilPrazo(prazoStr: string | null) {
|
||||
if (!prazoStr) return null
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
const prazo = new Date(prazoStr)
|
||||
prazo.setHours(0, 0, 0, 0)
|
||||
return Math.ceil((prazo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// Stats
|
||||
const totalProcessos = processos.length
|
||||
const totalNaoLidas = publicacoes.filter(p => !p.visualizado).length
|
||||
const prazosProximos = publicacoes.filter(p => {
|
||||
const days = getDaysUntilPrazo(p.prazoCalculado)
|
||||
return days !== null && days >= 0 && days <= 5
|
||||
}).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600/20">
|
||||
<Newspaper className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
Monitoramento de Publicações
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Acompanhe publicações dos Diários Oficiais</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={buscarNovasPublicacoes}
|
||||
disabled={buscando}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-zinc-300 transition-all hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${buscando ? 'animate-spin' : ''}`} />
|
||||
{buscando ? 'Buscando...' : 'Buscar Publicações'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/publicacoes/novo')}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Adicionar Processo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Processos Monitorados</p>
|
||||
<p className="text-2xl font-bold text-white">{totalProcessos}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-500/10">
|
||||
<Bell className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Publicações Não Lidas</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">{totalNaoLidas}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Prazos Próximos (5 dias)</p>
|
||||
<p className="text-2xl font-bold text-red-400">{prazosProximos}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<Filter className="h-4 w-4 text-zinc-500" />
|
||||
<select
|
||||
value={filterTipo}
|
||||
onChange={e => setFilterTipo(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todos os tipos</option>
|
||||
<option value="INTIMACAO">Intimação</option>
|
||||
<option value="CITACAO">Citação</option>
|
||||
<option value="SENTENCA">Sentença</option>
|
||||
<option value="DESPACHO">Despacho</option>
|
||||
<option value="ACORDAO">Acórdão</option>
|
||||
<option value="OUTROS">Outros</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterVisualizado}
|
||||
onChange={e => setFilterVisualizado(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
<option value="false">Não lidas</option>
|
||||
<option value="true">Lidas</option>
|
||||
</select>
|
||||
{processos.length > 0 && (
|
||||
<select
|
||||
value={filterProcesso}
|
||||
onChange={e => setFilterProcesso(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todos os processos</option>
|
||||
{processos.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.numeroProcesso} ({p.tribunal})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-indigo-400" />
|
||||
</div>
|
||||
) : processos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<Newspaper className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhum processo sendo monitorado</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/publicacoes/novo')}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Adicionar primeiro processo
|
||||
</button>
|
||||
</div>
|
||||
) : publicacoes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<Search className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhuma publicação encontrada</p>
|
||||
<button
|
||||
onClick={buscarNovasPublicacoes}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Buscar novas publicações
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{publicacoes.map(pub => {
|
||||
const config = tipoConfig[pub.tipo]
|
||||
const daysUntilPrazo = getDaysUntilPrazo(pub.prazoCalculado)
|
||||
const isUrgent = daysUntilPrazo !== null && daysUntilPrazo >= 0 && daysUntilPrazo <= 3
|
||||
const isOverdue = daysUntilPrazo !== null && daysUntilPrazo < 0
|
||||
const isExpanded = expandedId === pub.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pub.id}
|
||||
className={`group rounded-xl border bg-white/[0.02] transition-all hover:bg-white/[0.04] ${
|
||||
!pub.visualizado
|
||||
? 'border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]'
|
||||
: isUrgent || isOverdue
|
||||
? 'border-red-500/30'
|
||||
: 'border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Indicator */}
|
||||
{!pub.visualizado && (
|
||||
<div className="mt-1.5 h-2 w-2 rounded-full bg-indigo-500 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${config.bg} ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">{pub.diario}</span>
|
||||
<span className="text-xs text-zinc-600">•</span>
|
||||
<span className="text-xs text-zinc-500">{formatDate(pub.dataPublicacao)}</span>
|
||||
</div>
|
||||
{pub.processo && (
|
||||
<p className="mt-1 text-sm font-medium text-white">
|
||||
{pub.processo.numeroProcesso}
|
||||
<span className="ml-2 text-zinc-500 font-normal">({pub.processo.tribunal})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!pub.visualizado && (
|
||||
<button
|
||||
onClick={() => marcarComoLida(pub.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-indigo-500/10 hover:text-indigo-400"
|
||||
title="Marcar como lida"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : pub.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-white/10 hover:text-zinc-300"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview / Full content */}
|
||||
<p className={`mt-2 text-sm text-zinc-400 ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{pub.conteudo}
|
||||
</p>
|
||||
|
||||
{/* Prazo */}
|
||||
{pub.prazoCalculado && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span className={`text-xs font-medium ${
|
||||
isOverdue
|
||||
? 'text-red-400'
|
||||
: isUrgent
|
||||
? 'text-red-400 animate-pulse'
|
||||
: daysUntilPrazo !== null && daysUntilPrazo <= 7
|
||||
? 'text-yellow-400'
|
||||
: 'text-zinc-400'
|
||||
}`}>
|
||||
Prazo: {formatDate(pub.prazoCalculado)}
|
||||
{pub.prazoTipo && ` (${pub.prazoTipo})`}
|
||||
{daysUntilPrazo !== null && (
|
||||
<span className="ml-2">
|
||||
{isOverdue
|
||||
? `• Vencido há ${Math.abs(daysUntilPrazo)} dia(s)`
|
||||
: daysUntilPrazo === 0
|
||||
? '• Vence HOJE!'
|
||||
: daysUntilPrazo === 1
|
||||
? '• Vence AMANHÃ'
|
||||
: `• ${daysUntilPrazo} dias restantes`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processos monitorados section */}
|
||||
{processos.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-zinc-400" />
|
||||
Processos Monitorados
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{processos.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group rounded-xl border border-white/5 bg-white/[0.02] p-4 transition-all hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-sm font-medium text-white truncate">
|
||||
{p.numeroProcesso}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{p.tribunal}</p>
|
||||
{(p.parteAutora || p.parteRe) && (
|
||||
<p className="mt-1 text-xs text-zinc-600 truncate">
|
||||
{p.parteAutora && `Autor: ${p.parteAutora}`}
|
||||
{p.parteAutora && p.parteRe && ' • '}
|
||||
{p.parteRe && `Réu: ${p.parteRe}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{p.publicacoesNaoLidas > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 text-[10px] font-bold text-white">
|
||||
{p.publicacoesNaoLidas}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteProcesso(p.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-600 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400 transition-all"
|
||||
title="Remover"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/app/globals.css
Normal file
81
src/app/globals.css
Normal file
@@ -0,0 +1,81 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(16, 185, 129, 0.3); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(16, 185, 129, 0.5); }
|
||||
|
||||
::selection { background: rgba(16, 185, 129, 0.3); color: white; }
|
||||
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
|
||||
/* Utilities used by other pages */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
background: rgba(17, 24, 39, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.15);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #fff 0%, #34d399 50%, #14b8a6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(to right, #059669, #0d9488);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.input::placeholder { color: #6b7280; }
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
30
src/app/layout.tsx
Normal file
30
src/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-inter',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'LexMind — Inteligência Jurídica Autônoma',
|
||||
description: 'Plataforma de inteligência artificial construída para o Direito brasileiro.',
|
||||
icons: { icon: '/favicon.svg' },
|
||||
openGraph: {
|
||||
title: 'LexMind — Onde o Direito Encontra a Inteligência Artificial',
|
||||
description: 'Transforme horas de trabalho jurídico em minutos.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt-BR" className={`${inter.variable} dark`}>
|
||||
<body className={`${inter.className} antialiased bg-[#060a13] text-white`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
235
src/app/login/page.tsx
Normal file
235
src/app/login/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError(result.error === 'CredentialsSignin' ? 'Credenciais inválidas. Verifique e tente novamente.' : result.error)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch {
|
||||
setError('Falha na autenticação. Tente novamente em instantes.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 relative overflow-hidden bg-gradient-to-br from-[#070b14] via-[#0a0f1a] to-[#070b14]">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-teal-600/10 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-cyan-500/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="w-full max-w-md relative z-10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="text-center mb-8"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Logo size="lg" className="justify-center mb-4" />
|
||||
<p className="text-gray-400 text-sm font-medium">
|
||||
Acesse seu painel e retome de onde parou
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Login Card */}
|
||||
<motion.div
|
||||
className="card-glow p-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Autenticação</h2>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
className="flex items-center gap-3 p-4 rounded-xl mb-6 bg-red-500/10 border border-red-500/20 text-red-400"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Endereço de e-mail
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="advocacia@exemplo.com"
|
||||
required
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Senha
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="input pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remember + Forgot */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<input type="checkbox" className="rounded bg-white/5 border-teal-500/20 text-teal-600 focus:ring-teal-500/20" />
|
||||
Manter sessão ativa
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-sm text-teal-400 hover:text-teal-300 transition-colors">
|
||||
Recuperar senha
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Autenticando...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
Acessar Plataforma
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-teal-500/20" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 text-gray-400 bg-[#111827]">ou acesse via</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Login */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn('google')}
|
||||
className="flex items-center justify-center gap-3 p-3 rounded-xl bg-white/5 border border-teal-500/20 text-gray-300 hover:text-white hover:bg-white/10 hover:border-teal-500/30 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center gap-3 p-3 rounded-xl bg-white/5 border border-teal-500/20 text-gray-300 hover:text-white hover:bg-white/10 hover:border-teal-500/30 transition-all duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.374 0 0 5.373 0 12s5.374 12 12 12 12-5.373 12-12S18.626 0 12 0zm5.568 8.16c-.13 1.416-.69 4.772-1.132 6.34-.199.717-.575 1.008-.944 1.033-.823.053-1.448-.544-2.247-1.067-1.256-.82-1.963-1.33-3.177-2.132-1.408-.93-.493-1.444.309-2.28.21-.22 3.857-3.537 3.929-3.84.009-.038.017-.18-.067-.255s-.2-.045-.287-.027c-.124.026-2.091 1.328-5.905 3.903-.558.384-1.064.571-1.518.561-.5-.011-1.461-.283-2.176-.515-.877-.284-1.574-.434-1.513-.917.032-.251.385-.508 1.058-.77 4.127-1.8 6.878-2.988 8.253-3.565 3.936-1.648 4.753-1.933 5.288-1.943.117-.003.378.027.547.165.143.116.182.272.201.383.02.111.044.362.025.559z"/>
|
||||
</svg>
|
||||
Telegram
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Register Link */}
|
||||
<motion.div
|
||||
className="text-center mt-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<p className="text-gray-400">
|
||||
Ainda não possui cadastro?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-semibold text-teal-400 hover:text-teal-300 transition-colors"
|
||||
>
|
||||
Criar conta gratuitamente
|
||||
</Link>
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
499
src/app/page.tsx
Normal file
499
src/app/page.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Scale, ArrowRight, Mail, Sparkles, Shield, FileSearch, Brain } from 'lucide-react'
|
||||
|
||||
/* ─── Particle Field ─── */
|
||||
function Particles() {
|
||||
const [particles, setParticles] = useState<{ id: number; x: number; y: number; size: number; duration: number; delay: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const pts = Array.from({ length: 60 }, (_, i) => ({
|
||||
id: i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
duration: Math.random() * 20 + 15,
|
||||
delay: Math.random() * -20,
|
||||
}))
|
||||
setParticles(pts)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute rounded-full bg-emerald-400/20 animate-float"
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Feature Card ─── */
|
||||
function FeatureCard({
|
||||
icon: Icon,
|
||||
emoji,
|
||||
title,
|
||||
desc,
|
||||
delay,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
emoji: string
|
||||
title: string
|
||||
desc: string
|
||||
delay: number
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.7, delay, ease: [0.25, 0.4, 0.25, 1] }}
|
||||
className="group relative p-8 rounded-3xl bg-white/[0.03] backdrop-blur-xl border border-white/[0.08] hover:border-emerald-500/30 transition-all duration-700 hover:-translate-y-2 hover:shadow-2xl hover:shadow-emerald-500/[0.05]"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-emerald-500/[0.05] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-500/20 to-teal-500/10 border border-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-500">
|
||||
<span className="text-2xl">{emoji}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-3">{title}</h3>
|
||||
<p className="text-[15px] text-zinc-400 leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main Page ─── */
|
||||
export default function Home() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (email.trim()) {
|
||||
setSubmitted(true)
|
||||
}
|
||||
},
|
||||
[email]
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#040810] text-white overflow-hidden">
|
||||
{/* ═══ CSS Animations ═══ */}
|
||||
<style jsx global>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) translateX(0px); opacity: 0.2; }
|
||||
25% { transform: translateY(-30px) translateX(10px); opacity: 0.6; }
|
||||
50% { transform: translateY(-15px) translateX(-10px); opacity: 0.3; }
|
||||
75% { transform: translateY(-40px) translateX(5px); opacity: 0.5; }
|
||||
}
|
||||
.animate-float { animation: float linear infinite; }
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.9); opacity: 1; }
|
||||
100% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
.animate-pulse-ring {
|
||||
animation: pulse-ring 2.5s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.animate-glow { animation: glow 4s ease-in-out infinite; }
|
||||
|
||||
@keyframes scan-line {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100vh); }
|
||||
}
|
||||
.animate-scan {
|
||||
animation: scan-line 8s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* ═══ NAVBAR ═══ */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-[#040810]/60 backdrop-blur-2xl border-b border-white/[0.04]">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<Scale className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold tracking-tight">
|
||||
<span className="text-white">Lex</span>
|
||||
<span className="text-emerald-400">Mind</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors px-4 py-2"
|
||||
>
|
||||
Já tem conta? Acessar
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="hidden sm:block text-sm px-5 py-2 rounded-full border border-white/10 text-zinc-400 hover:text-white hover:border-white/20 transition-all"
|
||||
>
|
||||
Criar Conta
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ═══ HERO — Full Viewport ═══ */}
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 pt-16">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0">
|
||||
{/* Central mega glow */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full bg-emerald-500/[0.04] blur-[200px] animate-glow" />
|
||||
<div className="absolute top-1/3 left-[20%] w-[400px] h-[400px] rounded-full bg-teal-500/[0.03] blur-[150px] animate-glow" style={{ animationDelay: '-2s' }} />
|
||||
<div className="absolute bottom-1/4 right-[15%] w-[300px] h-[300px] rounded-full bg-cyan-500/[0.03] blur-[120px] animate-glow" style={{ animationDelay: '-4s' }} />
|
||||
|
||||
{/* Scan line */}
|
||||
<div className="absolute left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/20 to-transparent animate-scan" />
|
||||
|
||||
{/* Grid */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mounted && <Particles />}
|
||||
|
||||
<div className="relative max-w-4xl mx-auto text-center z-10">
|
||||
{/* "Em Breve" Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="inline-flex items-center gap-2.5 mb-10"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500/40 animate-pulse-ring" />
|
||||
<div className="relative px-6 py-2.5 rounded-full bg-emerald-500/[0.1] border border-emerald-500/30 backdrop-blur-sm">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-emerald-300 tracking-wide">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
EM BREVE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.4, ease: [0.25, 0.4, 0.25, 1] }}
|
||||
className="text-5xl sm:text-6xl lg:text-8xl font-extrabold tracking-tight leading-[1.05] mb-8"
|
||||
>
|
||||
<span className="block text-white/90">O Direito nunca mais</span>
|
||||
<span className="block bg-gradient-to-r from-emerald-300 via-teal-300 to-cyan-300 bg-clip-text text-transparent animate-gradient">
|
||||
será o mesmo.
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.7 }}
|
||||
className="text-lg sm:text-xl text-zinc-400 max-w-2xl mx-auto mb-14 leading-relaxed"
|
||||
>
|
||||
Inteligência Artificial que entende a lei como você.
|
||||
<br className="hidden sm:block" />
|
||||
<span className="text-zinc-300">Mais rápido, mais preciso, mais inteligente.</span>
|
||||
</motion.p>
|
||||
|
||||
{/* Email Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.9 }}
|
||||
className="max-w-lg mx-auto"
|
||||
>
|
||||
<p className="text-sm text-zinc-500 mb-4 font-medium">Seja o primeiro a ter acesso</p>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{!submitted ? (
|
||||
<motion.form
|
||||
key="form"
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row gap-3"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-600" />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="seu@email.com.br"
|
||||
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-white/[0.05] border border-white/[0.08] text-white placeholder-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-2 focus:ring-emerald-500/20 transition-all text-[15px]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="group px-8 py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-bold text-[15px] transition-all hover:shadow-2xl hover:shadow-emerald-500/30 hover:-translate-y-0.5 active:translate-y-0 flex items-center justify-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
Quero Acesso Antecipado
|
||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</button>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.div
|
||||
key="thanks"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="py-5 px-8 rounded-2xl bg-emerald-500/[0.1] border border-emerald-500/30 text-center"
|
||||
>
|
||||
<p className="text-emerald-300 font-semibold text-lg">🎉 Obrigado!</p>
|
||||
<p className="text-zinc-400 text-sm mt-1">Você será notificado assim que o LexMind estiver disponível.</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.2 }}
|
||||
className="text-xs text-zinc-600 mt-4 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Vagas limitadas para o lançamento
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 2 }}
|
||||
className="absolute bottom-10 left-1/2 -translate-x-1/2"
|
||||
>
|
||||
<div className="w-6 h-10 rounded-full border-2 border-white/10 flex items-start justify-center p-1.5">
|
||||
<motion.div
|
||||
animate={{ y: [0, 12, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
className="w-1.5 h-1.5 rounded-full bg-emerald-400/60"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ═══ FEATURES PREVIEW ═══ */}
|
||||
<section className="relative py-32 px-6">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[1px] bg-gradient-to-r from-transparent via-emerald-500/20 to-transparent" />
|
||||
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<p className="text-sm font-semibold text-emerald-400 mb-4 tracking-[0.2em] uppercase">
|
||||
O que está por vir
|
||||
</p>
|
||||
<h2 className="text-4xl sm:text-5xl font-extrabold text-white mb-6">
|
||||
Tecnologia que redefine<br />
|
||||
<span className="bg-gradient-to-r from-emerald-300 to-teal-300 bg-clip-text text-transparent">
|
||||
a prática jurídica
|
||||
</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
|
||||
<FeatureCard
|
||||
icon={Brain}
|
||||
emoji="🤖"
|
||||
title="IA Jurídica Avançada"
|
||||
desc="Peças processuais completas em menos de 30 segundos, com fundamentação técnica e jurisprudência atualizada."
|
||||
delay={0}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={FileSearch}
|
||||
emoji="⚖️"
|
||||
title="Jurisprudência Inteligente"
|
||||
desc="Pesquisa semântica em milhares de decisões de todos os tribunais do Brasil. Encontre o precedente perfeito."
|
||||
delay={0.15}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={FileSearch}
|
||||
emoji="📄"
|
||||
title="Análise de Processos"
|
||||
desc="Upload do PDF e receba parecer completo com legislação atualizada. Análise profunda em segundos."
|
||||
delay={0.3}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={Shield}
|
||||
emoji="🔒"
|
||||
title="Segurança Total"
|
||||
desc="Seus dados protegidos com criptografia de ponta a ponta. Conformidade total com a LGPD."
|
||||
delay={0.45}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ SOCIAL PROOF ═══ */}
|
||||
<section className="relative py-24 px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative p-12 sm:p-16 rounded-[2rem] bg-gradient-to-b from-white/[0.04] to-white/[0.01] border border-white/[0.06] text-center overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[300px] h-[200px] rounded-full bg-emerald-500/[0.05] blur-[100px]" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-center gap-1 mb-8">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1 + 0.3 }}
|
||||
className="text-2xl"
|
||||
>
|
||||
⭐
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-white/90 mb-4 leading-snug">
|
||||
“A nova era do Direito está chegando.”
|
||||
</p>
|
||||
<p className="text-zinc-400 text-lg mb-8">
|
||||
Desenvolvido por especialistas em IA e Direito
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-8 text-sm text-zinc-500">
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
Tecnologia de ponta
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-teal-500" />
|
||||
Escritórios inovadores
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-cyan-500" />
|
||||
Precisão jurídica
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══ SECOND CTA ═══ */}
|
||||
<section className="relative py-32 px-6">
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] rounded-full bg-emerald-500/[0.04] blur-[180px]" />
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative max-w-2xl mx-auto text-center"
|
||||
>
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-white mb-6">
|
||||
Não fique de fora da revolução
|
||||
</h2>
|
||||
<p className="text-lg text-zinc-400 mb-10">
|
||||
Garanta seu acesso antecipado e transforme sua prática jurídica com inteligência artificial.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group inline-flex items-center gap-3 px-10 py-5 rounded-full bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-bold text-lg transition-all hover:shadow-2xl hover:shadow-emerald-500/30 hover:-translate-y-0.5"
|
||||
>
|
||||
Criar Conta
|
||||
<ArrowRight className="w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ═══ FOOTER ═══ */}
|
||||
<footer className="border-t border-white/[0.04] bg-[#030609] py-12 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-md bg-gradient-to-br from-emerald-400 to-teal-600 flex items-center justify-center">
|
||||
<Scale className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-bold">
|
||||
<span className="text-white">Lex</span>
|
||||
<span className="text-emerald-400">Mind</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-zinc-600">
|
||||
<a href="/adv/termos" target="_blank" className="hover:text-zinc-400 transition-colors">Termos de Uso</a>
|
||||
<span className="text-zinc-800">|</span>
|
||||
<a href="/adv/privacidade" target="_blank" className="hover:text-zinc-400 transition-colors">Política de Privacidade</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Social placeholders */}
|
||||
{['𝕏', 'in', '▶'].map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center text-xs text-zinc-600 hover:text-zinc-400 hover:border-white/10 transition-all cursor-pointer"
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-white/[0.04] text-center">
|
||||
<p className="text-xs text-zinc-700">
|
||||
© 2026 LexMind Tecnologia Jurídica Ltda. Todos os direitos reservados.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2 mt-3 opacity-40 hover:opacity-70 transition-opacity duration-300">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-teal-400"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||
<span className="text-[0.65rem] text-teal-400/60 tracking-[0.15em] font-semibold">KISLANSKI INDUSTRIES</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
153
src/app/pecas/page.tsx
Normal file
153
src/app/pecas/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import Footer from '@/components/Footer'
|
||||
import { FileText, Shield, ArrowUpRight, Scale, Handshake, ScrollText, ChevronRight } from 'lucide-react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Peças Processuais com IA | LexMind - Gere Documentos Jurídicos',
|
||||
description: 'Gere peças processuais completas com inteligência artificial: petições iniciais, contestações, apelações, contratos e muito mais. Rápido, preciso e personalizado.',
|
||||
keywords: 'peças processuais, petição inicial, contestação, apelação, contrato, IA jurídica, documentos jurídicos',
|
||||
openGraph: {
|
||||
title: 'Peças Processuais com IA | LexMind',
|
||||
description: 'Gere peças processuais completas com inteligência artificial. Rápido, preciso e personalizado para advogados.',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
const documentTypes = [
|
||||
{
|
||||
title: 'Petição Inicial',
|
||||
slug: 'peticao-inicial',
|
||||
description: 'A peça que dá início ao processo judicial. Apresente os fatos, fundamentos jurídicos e pedidos de forma clara e persuasiva.',
|
||||
icon: FileText,
|
||||
color: 'from-teal-500 to-teal-600',
|
||||
features: ['Qualificação das partes', 'Fundamentação jurídica', 'Pedidos específicos'],
|
||||
},
|
||||
{
|
||||
title: 'Contestação',
|
||||
slug: 'contestacao',
|
||||
description: 'A defesa do réu contra a petição inicial. Impugne fatos, apresente preliminares e construa uma defesa sólida.',
|
||||
icon: Shield,
|
||||
color: 'from-teal-500 to-teal-700',
|
||||
features: ['Preliminares processuais', 'Impugnação dos fatos', 'Teses defensivas'],
|
||||
},
|
||||
{
|
||||
title: 'Apelação',
|
||||
slug: 'apelacao',
|
||||
description: 'Recurso contra sentença de primeiro grau. Demonstre os erros da decisão e busque a reforma no tribunal.',
|
||||
icon: Scale,
|
||||
color: 'from-teal-600 to-indigo-600',
|
||||
features: ['Razões recursais', 'Demonstração do error in judicando', 'Pedido de reforma'],
|
||||
},
|
||||
{
|
||||
title: 'Contrato',
|
||||
slug: 'contrato',
|
||||
description: 'Documentos contratuais completos e seguros juridicamente. Cláusulas claras, proteção para ambas as partes.',
|
||||
icon: Handshake,
|
||||
color: 'from-indigo-500 to-teal-600',
|
||||
features: ['Cláusulas essenciais', 'Proteção jurídica', 'Conformidade legal'],
|
||||
},
|
||||
]
|
||||
|
||||
export default function PecasPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="relative min-h-screen overflow-hidden">
|
||||
{/* Background */}
|
||||
<div className="fixed inset-0 bg-grid pointer-events-none z-0" />
|
||||
<div className="fixed inset-0 bg-radial-purple pointer-events-none z-0" />
|
||||
<div className="fixed top-1/4 -left-32 w-96 h-96 bg-teal-600/10 rounded-full blur-[128px] pointer-events-none" />
|
||||
<div className="fixed top-3/4 -right-32 w-96 h-96 bg-teal-600/10 rounded-full blur-[128px] pointer-events-none" />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative z-10 pt-32 pb-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-8">
|
||||
<ScrollText className="w-4 h-4 text-teal-400" />
|
||||
<span className="text-sm text-teal-300">Peças Processuais com IA</span>
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6">
|
||||
<span className="gradient-text">Todas as Peças Jurídicas</span>
|
||||
<br />
|
||||
<span className="text-white">em um só lugar</span>
|
||||
</h1>
|
||||
<p className="text-lg sm:text-xl text-gray-400 max-w-3xl mx-auto mb-12">
|
||||
Escolha o tipo de documento que você precisa e gere peças completas em minutos
|
||||
com inteligência artificial treinada em milhares de processos reais.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Grid de Peças */}
|
||||
<section className="relative z-10 pb-24 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{documentTypes.map((doc) => {
|
||||
const Icon = doc.icon
|
||||
return (
|
||||
<Link
|
||||
key={doc.slug}
|
||||
href={`/pecas/${doc.slug}`}
|
||||
className="group relative glass rounded-2xl p-8 hover:border-teal-500/30 transition-all duration-300 hover:glow-purple"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${doc.color} flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300`}>
|
||||
<Icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-2xl font-bold text-white group-hover:gradient-text transition-all">
|
||||
{doc.title}
|
||||
</h2>
|
||||
<ArrowUpRight className="w-5 h-5 text-gray-500 group-hover:text-teal-400 group-hover:translate-x-1 group-hover:-translate-y-1 transition-all" />
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4 leading-relaxed">
|
||||
{doc.description}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{doc.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<ChevronRight className="w-3 h-3 text-teal-500" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 inline-flex items-center gap-2 text-sm font-medium text-teal-400 group-hover:text-teal-300">
|
||||
Gerar {doc.title} com IA
|
||||
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative z-10 pb-24 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto text-center glass rounded-2xl p-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Não encontrou o que procura?
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-xl mx-auto">
|
||||
Nossa IA pode gerar qualquer tipo de peça processual. Acesse o painel e descreva
|
||||
o que você precisa — nós cuidamos do resto.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-teal-600 to-teal-600 text-white font-semibold hover:glow-purple-strong transition-all duration-300"
|
||||
>
|
||||
Começar Gratuitamente
|
||||
<ArrowUpRight className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
504
src/app/pricing/page.tsx
Normal file
504
src/app/pricing/page.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Check,
|
||||
X,
|
||||
Zap,
|
||||
Crown,
|
||||
Building2,
|
||||
Rocket,
|
||||
FlaskConical,
|
||||
ChevronDown,
|
||||
Sparkles,
|
||||
Shield,
|
||||
Users,
|
||||
Headphones,
|
||||
FileText,
|
||||
Search,
|
||||
MessageSquare,
|
||||
Layout,
|
||||
Code,
|
||||
GraduationCap,
|
||||
Clock,
|
||||
UserCheck,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PRICE_IDS = {
|
||||
TESTE: 'price_1Sw6zbGq8gFtWtyfFMuknvQQ',
|
||||
STARTER: 'price_1Sw6zbGq8gFtWtyf14b5v2iG',
|
||||
PRO: 'price_1Sw6zcGq8gFtWtyfRGDo58Q7',
|
||||
ENTERPRISE: 'price_1Sw6zcGq8gFtWtyfw9TNA7Ax',
|
||||
}
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: 'Gratuito',
|
||||
icon: Zap,
|
||||
monthlyPrice: 0,
|
||||
description: 'Ideal para experimentar os recursos e avaliar a plataforma.',
|
||||
features: [
|
||||
'5 créditos por mês',
|
||||
'Consultor IA básico',
|
||||
'3 pesquisas por dia',
|
||||
'Modelos essenciais',
|
||||
],
|
||||
cta: 'Experimentar Grátis',
|
||||
ctaHref: '/register',
|
||||
priceId: null,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
icon: Rocket,
|
||||
monthlyPrice: 97,
|
||||
description: 'Para advogados autônomos que precisam de mais recursos.',
|
||||
features: [
|
||||
'30 créditos por mês',
|
||||
'Consultor IA avançado',
|
||||
'Jurisprudência premium',
|
||||
'Modelos personalizáveis',
|
||||
'Atendimento por email',
|
||||
],
|
||||
cta: 'Assinar Starter',
|
||||
ctaHref: null,
|
||||
priceId: PRICE_IDS.STARTER,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
icon: Crown,
|
||||
monthlyPrice: 197,
|
||||
description: 'Para escritórios que exigem produtividade máxima.',
|
||||
features: [
|
||||
'100 créditos por mês',
|
||||
'Consultor IA avançado',
|
||||
'Jurisprudência premium',
|
||||
'Modelos personalizáveis',
|
||||
'Gestão de prazos',
|
||||
'Atendimento prioritário',
|
||||
],
|
||||
cta: 'Assinar Pro',
|
||||
ctaHref: null,
|
||||
priceId: PRICE_IDS.PRO,
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
icon: Building2,
|
||||
monthlyPrice: 497,
|
||||
description: 'Para operações jurídicas que exigem escala e governança.',
|
||||
features: [
|
||||
'Tudo do plano Pro',
|
||||
'500 créditos por mês',
|
||||
'Acesso à API',
|
||||
'Até 10 operadores',
|
||||
'SLA 99.9%',
|
||||
'Gestor de conta dedicado',
|
||||
],
|
||||
cta: 'Assinar Enterprise',
|
||||
ctaHref: null,
|
||||
priceId: PRICE_IDS.ENTERPRISE,
|
||||
highlighted: false,
|
||||
},
|
||||
]
|
||||
|
||||
const testPlan = {
|
||||
id: 'teste',
|
||||
name: 'Teste',
|
||||
icon: FlaskConical,
|
||||
monthlyPrice: 0.01,
|
||||
description: 'Plano de teste para validar o fluxo de pagamento.',
|
||||
features: [
|
||||
'100 créditos (teste)',
|
||||
'Todas as funcionalidades Pro',
|
||||
'Apenas R$0,01/mês',
|
||||
],
|
||||
cta: 'Testar por R$0,01',
|
||||
priceId: PRICE_IDS.TESTE,
|
||||
}
|
||||
|
||||
type FeatureRow = {
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
free: string | boolean
|
||||
starter: string | boolean
|
||||
pro: string | boolean
|
||||
enterprise: string | boolean
|
||||
}
|
||||
|
||||
const comparisonFeatures: FeatureRow[] = [
|
||||
{ label: 'Créditos mensais', icon: FileText, free: '5', starter: '30', pro: '100', enterprise: '500' },
|
||||
{ label: 'Chat com IA', icon: MessageSquare, free: 'Básico', starter: 'Avançado', pro: 'Avançado', enterprise: 'Avançado' },
|
||||
{ label: 'Busca jurisprudência', icon: Search, free: '3/dia', starter: 'Ilimitada', pro: 'Ilimitada', enterprise: 'Ilimitada' },
|
||||
{ label: 'Modelos personalizáveis', icon: Layout, free: false, starter: true, pro: true, enterprise: true },
|
||||
{ label: 'Gestão de prazos', icon: Clock, free: false, starter: false, pro: true, enterprise: true },
|
||||
{ label: 'Atendimento prioritário', icon: Headphones, free: false, starter: false, pro: true, enterprise: true },
|
||||
{ label: 'Integração via API', icon: Code, free: false, starter: false, pro: false, enterprise: true },
|
||||
{ label: 'Multi-usuários', icon: Users, free: false, starter: false, pro: false, enterprise: 'Até 10' },
|
||||
{ label: 'SLA garantido', icon: Shield, free: false, starter: false, pro: false, enterprise: '99.9%' },
|
||||
{ label: 'Gerente de conta', icon: UserCheck, free: false, starter: false, pro: false, enterprise: true },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'É possível alterar meu plano a qualquer momento?',
|
||||
a: 'Sim! Você pode fazer upgrade ou downgrade a qualquer momento. No upgrade, a diferença é cobrada proporcionalmente. No downgrade, o novo valor vale no próximo ciclo.',
|
||||
},
|
||||
{
|
||||
q: 'Existe um período de avaliação gratuita?',
|
||||
a: 'O plano Gratuito é permanente, sem necessidade de cartão de crédito. Para testar os planos pagos, use nosso plano Teste por apenas R$0,01.',
|
||||
},
|
||||
{
|
||||
q: 'Quais meios de pagamento são aceitos?',
|
||||
a: 'Aceitamos cartão de crédito (Visa, Mastercard, Elo, Amex) via Stripe, com total segurança.',
|
||||
},
|
||||
{
|
||||
q: 'Há cláusula de permanência mínima?',
|
||||
a: 'Não. Todos os planos são sem fidelidade. Você pode cancelar a qualquer momento pela sua área de gerenciamento.',
|
||||
},
|
||||
{
|
||||
q: 'Como é garantida a segurança dos dados?',
|
||||
a: 'Utilizamos criptografia AES-256, servidores no Brasil e estamos em conformidade total com a LGPD. Seus dados nunca são usados para treinar modelos de IA.',
|
||||
},
|
||||
{
|
||||
q: 'O que acontece quando meus créditos acabam?',
|
||||
a: 'Seus créditos são renovados automaticamente a cada ciclo de faturamento. Se acabarem antes, você pode fazer upgrade para um plano com mais créditos.',
|
||||
},
|
||||
]
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatPrice(price: number) {
|
||||
if (price === 0) return { integer: '0', decimal: '' }
|
||||
const str = price.toFixed(2).replace('.', ',')
|
||||
const [integer, decimal] = str.split(',')
|
||||
return { integer, decimal }
|
||||
}
|
||||
|
||||
function CellValue({ value }: { value: string | boolean }) {
|
||||
if (value === true) return <Check className="w-5 h-5 text-teal-400 mx-auto" />
|
||||
if (value === false) return <X className="w-5 h-5 text-gray-600 mx-auto" />
|
||||
return <span className="text-sm text-gray-200">{value}</span>
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter()
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
|
||||
async function handleSubscribe(priceId: string, planId: string) {
|
||||
setLoadingPlan(planId)
|
||||
try {
|
||||
const res = await fetch('/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ priceId }),
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
router.push('/register?plan=' + planId)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
} else {
|
||||
alert(data.error || 'Erro ao iniciar checkout')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Erro ao conectar com o servidor')
|
||||
} finally {
|
||||
setLoadingPlan(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="min-h-screen pt-24 pb-20">
|
||||
{/* ---- Bg effects ---- */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[900px] h-[500px] bg-radial-teal opacity-60" />
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[700px] h-[400px] bg-radial-bottom opacity-40" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* ---- Header ---- */}
|
||||
<section className="text-center mb-16 animate-fade-in-up">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full glass text-xs text-teal-300 mb-6">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Escolha o plano ideal para seu escritório
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-4">
|
||||
<span className="gradient-text">Investimento transparente,</span>
|
||||
<br />
|
||||
<span className="text-white">resultados mensuráveis</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-10">
|
||||
Encontre o plano certo para a operação do seu escritório. Valores claros, sem taxas ocultas. Liberdade total para cancelar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ---- Plan cards ---- */}
|
||||
<section className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{plans.map((plan, i) => {
|
||||
const { integer, decimal } = formatPrice(plan.monthlyPrice)
|
||||
const Icon = plan.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`relative rounded-2xl p-[1px] animate-fade-in-up ${
|
||||
plan.highlighted
|
||||
? 'bg-gradient-to-b from-teal-500 via-teal-600/60 to-teal-900/30'
|
||||
: 'bg-gradient-to-b from-teal-500/20 to-transparent'
|
||||
}`}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 z-10">
|
||||
<span className="px-4 py-1 rounded-full text-xs font-semibold bg-gradient-to-r from-teal-500 to-teal-600 text-white shadow-[0_0_20px_rgba(20,184,166,0.5)]">
|
||||
⭐ Recomendado
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-[15px] h-full flex flex-col p-8 ${
|
||||
plan.highlighted
|
||||
? 'bg-gradient-to-b from-[#0a1f1a] to-[#070b14]'
|
||||
: 'glass'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
plan.highlighted
|
||||
? 'bg-teal-500/30 text-teal-300'
|
||||
: 'bg-teal-500/10 text-teal-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">{plan.name}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-6">{plan.description}</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-sm text-gray-400">R$</span>
|
||||
<span className="text-5xl font-extrabold text-white">{integer}</span>
|
||||
{decimal && <span className="text-xl font-bold text-gray-300">,{decimal}</span>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{plan.monthlyPrice === 0 ? 'grátis para sempre' : '/mês'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-8 flex-1">
|
||||
{plan.features.map((feat) => (
|
||||
<li key={feat} className="flex items-start gap-2.5 text-sm text-gray-300">
|
||||
<Check className="w-4 h-4 text-teal-400 mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{plan.priceId ? (
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.priceId!, plan.id)}
|
||||
disabled={loadingPlan === plan.id}
|
||||
className={`block w-full py-3 rounded-xl text-sm font-semibold text-center transition-all disabled:opacity-70 ${
|
||||
plan.highlighted
|
||||
? 'bg-gradient-to-r from-teal-600 to-teal-500 text-white shadow-[0_0_30px_rgba(20,184,166,0.4)] hover:shadow-[0_0_40px_rgba(20,184,166,0.6)] hover:from-teal-500 hover:to-teal-400'
|
||||
: 'glass text-teal-300 hover:bg-teal-500/15 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{loadingPlan === plan.id ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Redirecionando...
|
||||
</span>
|
||||
) : (
|
||||
plan.cta
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href={plan.ctaHref || '/register'}
|
||||
className="block w-full py-3 rounded-xl text-sm font-semibold text-center transition-all glass text-teal-300 hover:bg-teal-500/15 hover:text-white"
|
||||
>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
{/* ---- Test plan banner ---- */}
|
||||
<section className="mb-28 animate-fade-in-up">
|
||||
<div className="relative overflow-hidden rounded-2xl border border-amber-500/20 bg-gradient-to-r from-amber-950/30 via-[#111b27] to-amber-950/30 p-6 sm:p-8">
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-amber-600/10 blur-3xl" />
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
|
||||
<FlaskConical className="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Plano Teste — R$0,01/mês</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Quer testar o fluxo completo? Assine por apenas 1 centavo e ganhe 100 créditos Pro.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSubscribe(testPlan.priceId, 'teste')}
|
||||
disabled={loadingPlan === 'teste'}
|
||||
className="shrink-0 inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold bg-amber-600/20 text-amber-300 border border-amber-500/30 hover:bg-amber-600/30 transition-all disabled:opacity-70"
|
||||
>
|
||||
{loadingPlan === 'teste' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Redirecionando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FlaskConical className="w-4 h-4" />
|
||||
Testar por R$0,01
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Feature comparison table ---- */}
|
||||
<section className="mb-28 animate-fade-in-up delay-400">
|
||||
<h2 className="text-3xl font-bold text-center mb-2 gradient-text">Comparativo detalhado</h2>
|
||||
<p className="text-gray-400 text-center mb-10">Analise cada recurso disponível por plano, lado a lado.</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl glass">
|
||||
<table className="w-full text-left min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-teal-500/10">
|
||||
<th className="py-4 px-6 text-sm text-gray-400 font-medium w-1/4">Funcionalidade</th>
|
||||
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Gratuito</th>
|
||||
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Starter</th>
|
||||
<th className="py-4 px-6 text-sm font-medium text-center text-teal-300 bg-teal-500/5">Pro</th>
|
||||
<th className="py-4 px-6 text-sm text-gray-400 font-medium text-center">Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparisonFeatures.map((row, i) => {
|
||||
const RowIcon = row.icon
|
||||
return (
|
||||
<tr
|
||||
key={row.label}
|
||||
className={`border-b border-teal-500/5 ${i % 2 === 0 ? '' : 'bg-teal-500/[0.02]'}`}
|
||||
>
|
||||
<td className="py-3.5 px-6 text-sm text-gray-300 flex items-center gap-2.5">
|
||||
<RowIcon className="w-4 h-4 text-teal-500/50 shrink-0" />
|
||||
{row.label}
|
||||
</td>
|
||||
<td className="py-3.5 px-6 text-center"><CellValue value={row.free} /></td>
|
||||
<td className="py-3.5 px-6 text-center"><CellValue value={row.starter} /></td>
|
||||
<td className="py-3.5 px-6 text-center bg-teal-500/5"><CellValue value={row.pro} /></td>
|
||||
<td className="py-3.5 px-6 text-center"><CellValue value={row.enterprise} /></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Trust badges ---- */}
|
||||
<section className="flex flex-wrap items-center justify-center gap-8 mb-28 animate-fade-in-up delay-500">
|
||||
{[
|
||||
{ icon: Shield, text: 'LGPD Compliant' },
|
||||
{ icon: Clock, text: 'SLA 99.9%' },
|
||||
{ icon: Users, text: '3.100+ escritórios' },
|
||||
{ icon: Sparkles, text: '47k+ peças redigidas' },
|
||||
].map((badge) => (
|
||||
<div key={badge.text} className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<badge.icon className="w-4 h-4 text-teal-500" />
|
||||
{badge.text}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* ---- FAQ ---- */}
|
||||
<section className="max-w-3xl mx-auto mb-20 animate-fade-in-up delay-600">
|
||||
<h2 className="text-3xl font-bold text-center mb-2 gradient-text">Perguntas frequentes</h2>
|
||||
<p className="text-gray-400 text-center mb-10">As dúvidas mais comuns sobre nossos planos.</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq) => (
|
||||
<details key={faq.q} className="group glass rounded-xl overflow-hidden">
|
||||
<summary className="flex items-center justify-between px-6 py-4 text-sm font-medium text-white hover:bg-teal-500/5 transition-colors">
|
||||
{faq.q}
|
||||
<ChevronDown className="w-4 h-4 text-gray-500 faq-chevron shrink-0 ml-4" />
|
||||
</summary>
|
||||
<div className="faq-content px-6 pb-4 text-sm text-gray-400 leading-relaxed">
|
||||
{faq.a}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- Bottom CTA ---- */}
|
||||
<section className="text-center glass rounded-2xl p-12 mb-8 animate-fade-in-up delay-700">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold mb-3 text-white">
|
||||
Pronto para modernizar sua operação jurídica?
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-8 max-w-xl mx-auto">
|
||||
Inicie sem custo e evolua conforme a demanda. Sem cartão, sem amarras.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link
|
||||
href="/register"
|
||||
className="px-8 py-3 rounded-xl text-sm font-semibold bg-gradient-to-r from-teal-600 to-teal-500 text-white shadow-[0_0_30px_rgba(20,184,166,0.4)] hover:shadow-[0_0_40px_rgba(20,184,166,0.6)] transition-all"
|
||||
>
|
||||
Experimentar Grátis
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleSubscribe(PRICE_IDS.PRO, 'pro')}
|
||||
disabled={loadingPlan === 'pro-bottom'}
|
||||
className="px-8 py-3 rounded-xl text-sm font-semibold glass text-teal-300 hover:bg-teal-500/15 hover:text-white transition-all"
|
||||
>
|
||||
Assinar Pro
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
331
src/app/privacidade/page.tsx
Normal file
331
src/app/privacidade/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Política de Privacidade — LexMind',
|
||||
description: 'Política de Privacidade da plataforma LexMind, em conformidade com a LGPD.',
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: 'coleta', label: '1. Informações que Coletamos' },
|
||||
{ id: 'uso', label: '2. Como Usamos seus Dados' },
|
||||
{ id: 'base-legal', label: '3. Base Legal para Tratamento' },
|
||||
{ id: 'armazenamento', label: '4. Armazenamento e Segurança' },
|
||||
{ id: 'compartilhamento', label: '5. Compartilhamento de Dados' },
|
||||
{ id: 'direitos', label: '6. Seus Direitos' },
|
||||
{ id: 'cookies', label: '7. Cookies e Tecnologias' },
|
||||
{ id: 'retencao', label: '8. Retenção de Dados' },
|
||||
{ id: 'transferencia', label: '9. Transferência Internacional' },
|
||||
{ id: 'menores', label: '10. Menores de Idade' },
|
||||
{ id: 'alteracoes', label: '11. Alterações na Política' },
|
||||
{ id: 'dpo', label: '12. Encarregado de Dados (DPO)' },
|
||||
{ id: 'anpd', label: '13. ANPD' },
|
||||
]
|
||||
|
||||
export default function PrivacidadePage() {
|
||||
return (
|
||||
<>
|
||||
{/* Navbar */}
|
||||
<nav className="fixed top-0 inset-x-0 z-50 bg-[#060a13]/80 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Link href="/">
|
||||
<Logo size="sm" />
|
||||
</Link>
|
||||
<Link href="/register" className="text-sm font-medium text-teal-400 hover:text-teal-300 transition-colors">
|
||||
Criar Conta →
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-screen bg-[#060a13] pt-28 pb-20">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-400 text-xs font-medium mb-4">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Privacidade & LGPD
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Política de Privacidade</h1>
|
||||
<p className="text-gray-400 text-lg">Última atualização: Fevereiro de 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-12">
|
||||
{/* Table of Contents */}
|
||||
<aside className="lg:w-72 flex-shrink-0">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Índice</h3>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((s) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`#${s.id}`}
|
||||
className="block text-sm text-gray-500 hover:text-teal-400 transition-colors py-1.5 pl-3 border-l border-white/[0.06] hover:border-teal-500/50"
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<article className="flex-1 min-w-0">
|
||||
<p className="text-gray-400 leading-relaxed mb-8">
|
||||
A <strong className="text-white">LexMind Tecnologia Jurídica Ltda.</strong> (“LexMind”, “nós”) está comprometida com a proteção dos seus dados pessoais. Esta Política de Privacidade descreve como coletamos, utilizamos, armazenamos e protegemos suas informações, em conformidade com a <strong className="text-white">Lei Geral de Proteção de Dados (Lei nº 13.709/2018 — LGPD)</strong> e demais normas aplicáveis.
|
||||
</p>
|
||||
|
||||
<Section id="coleta" title="1. Informações que Coletamos">
|
||||
<h3>1.1 Dados Cadastrais</h3>
|
||||
<ul>
|
||||
<li>Nome completo, e-mail, telefone;</li>
|
||||
<li>Número de inscrição na OAB e seccional;</li>
|
||||
<li>Dados de faturamento para processamento de pagamentos.</li>
|
||||
</ul>
|
||||
<h3>1.2 Documentos Jurídicos</h3>
|
||||
<ul>
|
||||
<li>Peças processuais, contratos e documentos inseridos na plataforma para processamento;</li>
|
||||
<li>Consultas realizadas ao consultor jurídico virtual;</li>
|
||||
<li>Pesquisas de jurisprudência e seus resultados.</li>
|
||||
</ul>
|
||||
<h3>1.3 Dados de Uso</h3>
|
||||
<ul>
|
||||
<li>Endereço IP, tipo de navegador, sistema operacional;</li>
|
||||
<li>Páginas visitadas, funcionalidades utilizadas, horários de acesso;</li>
|
||||
<li>Dados de interação com a plataforma para melhoria dos serviços.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="uso" title="2. Como Usamos seus Dados">
|
||||
<p>Utilizamos seus dados para as seguintes finalidades:</p>
|
||||
<ul>
|
||||
<li><strong>Prestação do serviço:</strong> processamento de documentos, geração de peças jurídicas, pesquisa de jurisprudência;</li>
|
||||
<li><strong>Gestão da conta:</strong> autenticação, comunicações transacionais, suporte ao cliente;</li>
|
||||
<li><strong>Cobrança:</strong> processamento de pagamentos e emissão de notas fiscais;</li>
|
||||
<li><strong>Melhoria do serviço:</strong> análise de uso agregado e anonimizado para aprimoramento da plataforma;</li>
|
||||
<li><strong>Segurança:</strong> prevenção de fraudes, abusos e acessos não autorizados;</li>
|
||||
<li><strong>Comunicação:</strong> envio de atualizações sobre o serviço, alterações nos termos e novidades (com opção de descadastramento);</li>
|
||||
<li><strong>Cumprimento legal:</strong> atendimento a obrigações legais e regulatórias.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="base-legal" title="3. Base Legal para Tratamento">
|
||||
<p>O tratamento dos seus dados pessoais é realizado com fundamento nas seguintes bases legais previstas no <strong>Art. 7º da LGPD</strong>:</p>
|
||||
<div className="overflow-x-auto my-4">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Base Legal</th>
|
||||
<th>Aplicação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Execução de contrato</strong> (Art. 7º, V)</td>
|
||||
<td>Prestação dos serviços contratados</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Consentimento</strong> (Art. 7º, I)</td>
|
||||
<td>Envio de comunicações de marketing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Legítimo interesse</strong> (Art. 7º, IX)</td>
|
||||
<td>Melhoria dos serviços, segurança da plataforma</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Cumprimento de obrigação legal</strong> (Art. 7º, II)</td>
|
||||
<td>Obrigações fiscais, regulatórias e judiciais</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section id="armazenamento" title="4. Armazenamento e Segurança">
|
||||
<p>Adotamos medidas técnicas e organizacionais para proteger seus dados:</p>
|
||||
<ul>
|
||||
<li><strong>Infraestrutura:</strong> dados armazenados em servidores seguros da <strong>DigitalOcean</strong>, com redundância e backups regulares;</li>
|
||||
<li><strong>Criptografia:</strong> dados em trânsito protegidos por TLS 1.3; dados sensíveis criptografados em repouso;</li>
|
||||
<li><strong>Controle de acesso:</strong> acesso restrito a colaboradores autorizados, com autenticação multifator;</li>
|
||||
<li><strong>Monitoramento:</strong> sistemas de detecção de intrusões e monitoramento contínuo;</li>
|
||||
<li><strong>Senhas:</strong> armazenadas com hash seguro (bcrypt), nunca em texto puro.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Apesar de nossos esforços, nenhum sistema é 100% seguro. Em caso de incidente de segurança, notificaremos os afetados e a ANPD conforme exigido pela LGPD.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="compartilhamento" title="5. Compartilhamento de Dados">
|
||||
<p>Seus dados podem ser compartilhados com os seguintes terceiros, estritamente para as finalidades descritas:</p>
|
||||
|
||||
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
|
||||
<h4 className="text-white font-semibold mb-2">🤖 OpenAI (Processamento de IA)</h4>
|
||||
<p className="text-sm">
|
||||
Os dados inseridos na plataforma são enviados à API da OpenAI para processamento por modelos de inteligência artificial. <strong className="text-teal-400">Seus dados NÃO são utilizados pela OpenAI para treinamento de modelos.</strong> Utilizamos a API com a opção de “zero data retention” sempre que disponível.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
|
||||
<h4 className="text-white font-semibold mb-2">💳 Stripe (Pagamentos)</h4>
|
||||
<p className="text-sm">
|
||||
Dados de pagamento são processados pelo Stripe, certificado PCI DSS Nível 1. A LexMind não armazena dados completos de cartão de crédito em seus servidores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>Não vendemos, alugamos ou comercializamos seus dados pessoais. Outros compartilhamentos poderão ocorrer apenas:</p>
|
||||
<ul>
|
||||
<li>Por determinação judicial ou exigência legal;</li>
|
||||
<li>Para proteger direitos, propriedade ou segurança da LexMind e seus usuários;</li>
|
||||
<li>Com prestadores de serviço essenciais, mediante contratos de confidencialidade e proteção de dados.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="direitos" title="6. Seus Direitos">
|
||||
<p>Conforme o <strong>Art. 18 da LGPD</strong>, você tem direito a:</p>
|
||||
<ul>
|
||||
<li><strong>Confirmação e acesso:</strong> saber se tratamos seus dados e obter cópia;</li>
|
||||
<li><strong>Correção:</strong> atualizar dados incompletos, inexatos ou desatualizados;</li>
|
||||
<li><strong>Anonimização, bloqueio ou eliminação:</strong> de dados desnecessários ou tratados em desconformidade;</li>
|
||||
<li><strong>Portabilidade:</strong> transferir seus dados a outro fornecedor, mediante requisição expressa;</li>
|
||||
<li><strong>Eliminação:</strong> solicitar a exclusão de dados tratados com base no consentimento;</li>
|
||||
<li><strong>Informação:</strong> sobre entidades com as quais compartilhamos seus dados;</li>
|
||||
<li><strong>Revogação do consentimento:</strong> a qualquer momento, sem prejuízo do tratamento anterior;</li>
|
||||
<li><strong>Oposição:</strong> ao tratamento realizado com base em legítimo interesse, se aplicável.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Para exercer seus direitos, entre em contato pelo e-mail{' '}
|
||||
<a href="mailto:privacidade@lexmind.adv.br" className="text-teal-400 hover:text-teal-300 underline">privacidade@lexmind.adv.br</a>.
|
||||
Responderemos em até 15 (quinze) dias úteis.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="cookies" title="7. Cookies e Tecnologias">
|
||||
<p>Utilizamos cookies e tecnologias similares para:</p>
|
||||
<ul>
|
||||
<li><strong>Cookies essenciais:</strong> necessários para o funcionamento da plataforma (autenticação, sessão);</li>
|
||||
<li><strong>Cookies de desempenho:</strong> análise de uso agregado para melhoria da experiência;</li>
|
||||
<li><strong>Cookies de funcionalidade:</strong> memorizar preferências do usuário.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Você pode gerenciar suas preferências de cookies nas configurações do navegador. A desativação de cookies essenciais poderá afetar o funcionamento da plataforma.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="retencao" title="8. Retenção de Dados">
|
||||
<ul>
|
||||
<li><strong>Dados da conta:</strong> mantidos enquanto a conta estiver ativa e por 90 (noventa) dias após o cancelamento;</li>
|
||||
<li><strong>Documentos gerados:</strong> mantidos enquanto a conta estiver ativa; após cancelamento, excluídos em até 90 dias;</li>
|
||||
<li><strong>Dados de faturamento:</strong> mantidos por 5 (cinco) anos conforme legislação fiscal;</li>
|
||||
<li><strong>Dados de uso e logs:</strong> mantidos por até 6 (seis) meses para fins de segurança;</li>
|
||||
<li><strong>Dados anonimizados:</strong> podem ser mantidos indefinidamente para fins estatísticos.</li>
|
||||
</ul>
|
||||
<p>Ao término do período de retenção, os dados serão eliminados de forma segura.</p>
|
||||
</Section>
|
||||
|
||||
<Section id="transferencia" title="9. Transferência Internacional">
|
||||
<p>
|
||||
Alguns dos nossos prestadores de serviço, como a <strong>OpenAI</strong>, estão localizados nos <strong>Estados Unidos da América</strong>. A transferência internacional de dados é realizada com as seguintes salvaguardas:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Cláusulas contratuais padrão de proteção de dados;</li>
|
||||
<li>Avaliação do nível de proteção do país destinatário;</li>
|
||||
<li>Garantia de que os dados serão tratados com nível de proteção compatível com a LGPD;</li>
|
||||
<li>Utilização de APIs com opções de “zero data retention” quando disponíveis.</li>
|
||||
</ul>
|
||||
<p>
|
||||
A transferência é fundamentada no Art. 33 da LGPD, em especial nos incisos II (cláusulas contratuais) e VIII (consentimento informado).
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="menores" title="10. Menores de Idade">
|
||||
<p>
|
||||
A plataforma LexMind é destinada exclusivamente a profissionais do Direito e maiores de 18 (dezoito) anos. <strong>Não coletamos intencionalmente dados de menores de idade.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Caso identifiquemos que dados de menor de idade foram coletados inadvertidamente, estes serão excluídos imediatamente. Se você tiver conhecimento de que um menor forneceu dados à plataforma, entre em contato conosco.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="alteracoes" title="11. Alterações na Política">
|
||||
<p>
|
||||
Esta Política de Privacidade pode ser atualizada periodicamente. As alterações serão comunicadas por:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Notificação por e-mail;</li>
|
||||
<li>Aviso destacado na plataforma;</li>
|
||||
<li>Atualização da data nesta página.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Recomendamos a revisão periódica desta página. O uso continuado da plataforma após a publicação de alterações constitui ciência e concordância.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="dpo" title="12. Encarregado de Dados (DPO) e Contato">
|
||||
<p>
|
||||
Nos termos do Art. 41 da LGPD, indicamos o seguinte Encarregado pelo Tratamento de Dados Pessoais:
|
||||
</p>
|
||||
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
|
||||
<ul className="space-y-2 list-none pl-0">
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Empresa:</span>
|
||||
<span className="text-white">LexMind Tecnologia Jurídica Ltda.</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">E-mail do DPO:</span>
|
||||
<a href="mailto:privacidade@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">privacidade@lexmind.adv.br</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">E-mail geral:</span>
|
||||
<a href="mailto:contato@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">contato@lexmind.adv.br</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Website:</span>
|
||||
<a href="https://lexmind.adv.br" className="text-teal-400 hover:text-teal-300">lexmind.adv.br</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section id="anpd" title="13. Autoridade Nacional de Proteção de Dados (ANPD)">
|
||||
<p>
|
||||
Caso entenda que o tratamento de seus dados pessoais viola a LGPD, você tem direito de apresentar petição à Autoridade Nacional de Proteção de Dados (ANPD):
|
||||
</p>
|
||||
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
|
||||
<ul className="space-y-2 list-none pl-0">
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Website:</span>
|
||||
<a href="https://www.gov.br/anpd" target="_blank" rel="noopener noreferrer" className="text-teal-400 hover:text-teal-300">www.gov.br/anpd</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Canal de denúncias:</span>
|
||||
<span className="text-white">Peticionamento eletrônico no site da ANPD</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
Recomendamos que, antes de acionar a ANPD, entre em contato com nosso Encarregado de Dados para tentarmos resolver sua solicitação diretamente.
|
||||
</p>
|
||||
</Section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section id={id} className="mb-12 scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-4 pb-2 border-b border-white/[0.06]">{title}</h2>
|
||||
<div className="text-gray-400 leading-relaxed space-y-4 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-200 [&_h3]:mt-6 [&_h3]:mb-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_li]:text-gray-400 [&_strong]:text-white [&_table]:w-full [&_table]:text-sm [&_th]:text-left [&_th]:text-gray-300 [&_th]:font-semibold [&_th]:pb-3 [&_th]:border-b [&_th]:border-white/10 [&_td]:py-3 [&_td]:text-gray-400 [&_td]:border-b [&_td]:border-white/[0.04] [&_tr:last-child_td]:border-0">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
512
src/app/register/page.tsx
Normal file
512
src/app/register/page.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
'use client'
|
||||
|
||||
import { useState, Suspense } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const BRAZILIAN_STATES = [
|
||||
{ value: 'AC', label: 'Acre' },
|
||||
{ value: 'AL', label: 'Alagoas' },
|
||||
{ value: 'AP', label: 'Amapá' },
|
||||
{ value: 'AM', label: 'Amazonas' },
|
||||
{ value: 'BA', label: 'Bahia' },
|
||||
{ value: 'CE', label: 'Ceará' },
|
||||
{ value: 'DF', label: 'Distrito Federal' },
|
||||
{ value: 'ES', label: 'Espírito Santo' },
|
||||
{ value: 'GO', label: 'Goiás' },
|
||||
{ value: 'MA', label: 'Maranhão' },
|
||||
{ value: 'MT', label: 'Mato Grosso' },
|
||||
{ value: 'MS', label: 'Mato Grosso do Sul' },
|
||||
{ value: 'MG', label: 'Minas Gerais' },
|
||||
{ value: 'PA', label: 'Pará' },
|
||||
{ value: 'PB', label: 'Paraíba' },
|
||||
{ value: 'PR', label: 'Paraná' },
|
||||
{ value: 'PE', label: 'Pernambuco' },
|
||||
{ value: 'PI', label: 'Piauí' },
|
||||
{ value: 'RJ', label: 'Rio de Janeiro' },
|
||||
{ value: 'RN', label: 'Rio Grande do Norte' },
|
||||
{ value: 'RS', label: 'Rio Grande do Sul' },
|
||||
{ value: 'RO', label: 'Rondônia' },
|
||||
{ value: 'RR', label: 'Roraima' },
|
||||
{ value: 'SC', label: 'Santa Catarina' },
|
||||
{ value: 'SP', label: 'São Paulo' },
|
||||
{ value: 'SE', label: 'Sergipe' },
|
||||
{ value: 'TO', label: 'Tocantins' },
|
||||
]
|
||||
|
||||
interface FormErrors {
|
||||
name?: string
|
||||
email?: string
|
||||
oabNumber?: string
|
||||
oabState?: string
|
||||
phone?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
terms?: string
|
||||
}
|
||||
|
||||
function RegisterContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: searchParams.get('email') || '',
|
||||
oabNumber: '',
|
||||
oabState: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const updateField = (field: string, value: string | boolean) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }))
|
||||
setServerError('')
|
||||
}
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: FormErrors = {}
|
||||
|
||||
if (!form.name.trim()) newErrors.name = 'Nome é obrigatório'
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = 'Email é obrigatório'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
|
||||
newErrors.email = 'Email inválido'
|
||||
}
|
||||
|
||||
if (form.oabNumber && !/^\d{3,7}$/.test(form.oabNumber.replace(/\D/g, ''))) {
|
||||
newErrors.oabNumber = 'Número OAB inválido (3-7 dígitos)'
|
||||
}
|
||||
|
||||
if (form.oabNumber && !form.oabState) {
|
||||
newErrors.oabState = 'Selecione o estado da OAB'
|
||||
}
|
||||
|
||||
if (form.phone && !/^\(?\d{2}\)?\s?\d{4,5}-?\d{4}$/.test(form.phone.replace(/\s/g, ''))) {
|
||||
newErrors.phone = 'Telefone inválido'
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
newErrors.password = 'Senha é obrigatória'
|
||||
} else if (form.password.length < 8) {
|
||||
newErrors.password = 'Senha deve ter pelo menos 8 caracteres'
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Senhas não conferem'
|
||||
}
|
||||
|
||||
if (!form.acceptTerms) {
|
||||
newErrors.terms = 'Você deve aceitar os termos'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setServerError('')
|
||||
|
||||
if (!validate()) return
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim().toLowerCase(),
|
||||
password: form.password,
|
||||
oabNumber: form.oabNumber || undefined,
|
||||
oabState: form.oabState || undefined,
|
||||
phone: form.phone || undefined,
|
||||
plan: searchParams.get('plan') || 'free',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setServerError(data.error || 'Erro ao criar conta')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto sign-in after registration
|
||||
const signInResult = await signIn('credentials', {
|
||||
email: form.email.trim().toLowerCase(),
|
||||
password: form.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (signInResult?.error) {
|
||||
// Account created but sign-in failed — redirect to login
|
||||
router.push('/login')
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch {
|
||||
setServerError('Erro ao criar conta. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 py-12 relative overflow-hidden bg-gradient-to-br from-[#070b14] via-[#0a0f1a] to-[#070b14]">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-teal-600/10 rounded-full blur-[120px] animate-float" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-cyan-500/10 rounded-full blur-[100px] animate-float-delayed" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-gradient-radial from-teal-500/5 to-transparent rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Grid Pattern */}
|
||||
<div className="fixed inset-0 bg-grid-teal pointer-events-none" />
|
||||
|
||||
<motion.div
|
||||
className="w-full max-w-lg relative z-10"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="text-center mb-8"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Logo size="lg" className="justify-center mb-4" />
|
||||
<p className="text-gray-400 text-sm font-medium">
|
||||
Cadastre-se e descubra o que a inteligência artificial pode fazer pela sua advocacia
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Registration Card */}
|
||||
<motion.div
|
||||
className="card-glow p-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Criar Cadastro</h2>
|
||||
|
||||
{/* Plan Badge */}
|
||||
{searchParams.get('plan') && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-gradient-to-r from-teal-500/10 to-cyan-500/10 border border-teal-500/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-teal-500 to-cyan-500 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">Plano {searchParams.get('plan') === 'pro' ? 'Pro' : 'Enterprise'}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
{searchParams.get('plan') === 'pro' ? 'R$ 97/mês' : 'R$ 297/mês'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverError && (
|
||||
<motion.div
|
||||
className="flex items-center gap-3 p-4 rounded-xl mb-6 bg-red-500/10 border border-red-500/20 text-red-400"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{serverError}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Nome completo <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
placeholder="Dr. João Silva"
|
||||
required
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{errors.name && <p className="text-xs mt-1 text-red-400">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Email <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => updateField('email', e.target.value)}
|
||||
placeholder="seu@email.com"
|
||||
required
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-xs mt-1 text-red-400">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
{/* OAB Number + State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Número OAB
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={form.oabNumber}
|
||||
onChange={(e) => updateField('oabNumber', e.target.value)}
|
||||
placeholder="123456"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{errors.oabNumber && <p className="text-xs mt-1 text-red-400">{errors.oabNumber}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Seccional
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none z-10">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<select
|
||||
value={form.oabState}
|
||||
onChange={(e) => updateField('oabState', e.target.value)}
|
||||
className="input pl-12 pr-4 appearance-none cursor-pointer text-white"
|
||||
>
|
||||
<option value="" className="bg-gray-800">UF</option>
|
||||
{BRAZILIAN_STATES.map((state) => (
|
||||
<option key={state.value} value={state.value} className="bg-gray-800">
|
||||
{state.value} - {state.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{errors.oabState && <p className="text-xs mt-1 text-red-400">{errors.oabState}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Telefone
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
value={form.phone}
|
||||
onChange={(e) => updateField('phone', e.target.value)}
|
||||
placeholder="(11) 99999-9999"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
{errors.phone && <p className="text-xs mt-1 text-red-400">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Senha <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
required
|
||||
className="input pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs mt-1 text-red-400">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Confirmar senha <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => updateField('confirmPassword', e.target.value)}
|
||||
placeholder="Repita a senha"
|
||||
required
|
||||
className="input pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-teal-400 transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && <p className="text-xs mt-1 text-red-400">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
|
||||
{/* Terms */}
|
||||
<div>
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.acceptTerms}
|
||||
onChange={(e) => updateField('acceptTerms', e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded bg-white/5 border-teal-500/20 text-teal-600 focus:ring-teal-500/20 focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-gray-400 leading-relaxed">
|
||||
Ao se cadastrar, você concorda com nossos{' '}
|
||||
<Link href="/termos" target="_blank" className="text-teal-400 hover:text-teal-300 underline">
|
||||
Termos de Uso
|
||||
</Link>{' '}
|
||||
e{' '}
|
||||
<Link href="/privacidade" target="_blank" className="text-teal-400 hover:text-teal-300 underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
{errors.terms && <p className="text-xs mt-1 text-red-400">{errors.terms}</p>}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Processando cadastro...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
Criar Cadastro Grátis
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Free Features */}
|
||||
{!searchParams.get('plan') || searchParams.get('plan') === 'free' ? (
|
||||
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white text-sm mb-1">Seu plano inicial contempla:</h4>
|
||||
<ul className="text-xs text-gray-400 space-y-1">
|
||||
<li>• 5 documentos jurídicos por mês</li>
|
||||
<li>• 10 consultas de jurisprudência</li>
|
||||
<li>• Consultor IA básico</li>
|
||||
<li>• Acervo essencial de modelos</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{/* Login Link */}
|
||||
<motion.div
|
||||
className="text-center mt-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<p className="text-gray-400">
|
||||
Já possui cadastro?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-semibold text-teal-400 hover:text-teal-300 transition-colors"
|
||||
>
|
||||
Acessar plataforma
|
||||
</Link>
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#070b14]" />}>
|
||||
<RegisterContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
301
src/app/termos/page.tsx
Normal file
301
src/app/termos/page.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import Footer from '@/components/Footer'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Termos de Uso — LexMind',
|
||||
description: 'Termos de Uso da plataforma LexMind de inteligência artificial jurídica.',
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: 'aceitacao', label: '1. Aceitação dos Termos' },
|
||||
{ id: 'descricao', label: '2. Descrição do Serviço' },
|
||||
{ id: 'cadastro', label: '3. Cadastro e Conta' },
|
||||
{ id: 'planos', label: '4. Planos e Pagamento' },
|
||||
{ id: 'uso', label: '5. Uso Aceitável' },
|
||||
{ id: 'propriedade', label: '6. Propriedade Intelectual' },
|
||||
{ id: 'limitacao', label: '7. Limitação de Responsabilidade' },
|
||||
{ id: 'disponibilidade', label: '8. Disponibilidade do Serviço' },
|
||||
{ id: 'cancelamento', label: '9. Cancelamento e Reembolso' },
|
||||
{ id: 'modificacoes', label: '10. Modificações dos Termos' },
|
||||
{ id: 'lei', label: '11. Lei Aplicável e Foro' },
|
||||
{ id: 'contato', label: '12. Contato' },
|
||||
]
|
||||
|
||||
export default function TermosPage() {
|
||||
return (
|
||||
<>
|
||||
{/* Navbar */}
|
||||
<nav className="fixed top-0 inset-x-0 z-50 bg-[#060a13]/80 backdrop-blur-xl border-b border-white/[0.06]">
|
||||
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Link href="/">
|
||||
<Logo size="sm" />
|
||||
</Link>
|
||||
<Link href="/register" className="text-sm font-medium text-teal-400 hover:text-teal-300 transition-colors">
|
||||
Criar Conta →
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-screen bg-[#060a13] pt-28 pb-20">
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-teal-500/10 border border-teal-500/20 text-teal-400 text-xs font-medium mb-4">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documento Legal
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">Termos de Uso</h1>
|
||||
<p className="text-gray-400 text-lg">Última atualização: Fevereiro de 2026</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-12">
|
||||
{/* Table of Contents - Sidebar */}
|
||||
<aside className="lg:w-72 flex-shrink-0">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">Índice</h3>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((s) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`#${s.id}`}
|
||||
className="block text-sm text-gray-500 hover:text-teal-400 transition-colors py-1.5 pl-3 border-l border-white/[0.06] hover:border-teal-500/50"
|
||||
>
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<article className="flex-1 min-w-0 prose-custom">
|
||||
<p className="text-gray-400 leading-relaxed mb-8">
|
||||
Bem-vindo à <strong className="text-white">LexMind</strong>. Estes Termos de Uso (“Termos”) regulam o acesso e a utilização da plataforma disponível em{' '}
|
||||
<strong className="text-teal-400">lexmind.adv.br</strong>, operada por LexMind Tecnologia Jurídica Ltda. Ao acessar ou utilizar nossos serviços, você declara ter lido, compreendido e concordado integralmente com estes Termos.
|
||||
</p>
|
||||
|
||||
<Section id="aceitacao" title="1. Aceitação dos Termos">
|
||||
<p>
|
||||
Ao criar uma conta, acessar ou utilizar qualquer funcionalidade da plataforma LexMind, você concorda em estar vinculado a estes Termos de Uso e à nossa{' '}
|
||||
<Link href="/privacidade" className="text-teal-400 hover:text-teal-300 underline">
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
. Caso não concorde com qualquer disposição, não utilize a plataforma.
|
||||
</p>
|
||||
<p>
|
||||
Você declara ser maior de 18 anos e possuir capacidade civil plena para celebrar este acordo, ou estar devidamente representado.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="descricao" title="2. Descrição do Serviço">
|
||||
<p>
|
||||
A LexMind é uma plataforma de inteligência artificial jurídica (SaaS) que oferece ferramentas para profissionais do Direito, incluindo:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Redação de Peças Processuais:</strong> elaboração assistida por IA de petições, contestações, recursos e pareceres;</li>
|
||||
<li><strong>Consulta Jurídica Inteligente:</strong> consultor virtual baseado em legislação, doutrina e jurisprudência brasileira;</li>
|
||||
<li><strong>Mineração de Jurisprudência:</strong> pesquisa semântica profunda em acórdãos de tribunais superiores e regionais;</li>
|
||||
<li><strong>Gestão Inteligente de Prazos:</strong> monitoramento automatizado de prazos processuais com alertas;</li>
|
||||
<li><strong>Auditoria de Contratos:</strong> análise automatizada de cláusulas, identificação de riscos e sugestões de redação;</li>
|
||||
<li><strong>Acervo de Modelos:</strong> templates jurídicos especializados por área do direito.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="cadastro" title="3. Cadastro e Conta">
|
||||
<p>
|
||||
Para acessar os recursos da plataforma, é necessário criar uma conta fornecendo informações verdadeiras, completas e atualizadas. Você é o único responsável por:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Manter a confidencialidade de suas credenciais de acesso (e-mail e senha);</li>
|
||||
<li>Todas as atividades realizadas em sua conta;</li>
|
||||
<li>Notificar imediatamente a LexMind sobre qualquer uso não autorizado de sua conta;</li>
|
||||
<li>Manter seus dados cadastrais atualizados.</li>
|
||||
</ul>
|
||||
<p>
|
||||
A LexMind reserva-se o direito de suspender ou encerrar contas que violem estes Termos ou que apresentem dados cadastrais falsos ou incompletos.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="planos" title="4. Planos e Pagamento">
|
||||
<p>A LexMind oferece os seguintes planos de assinatura:</p>
|
||||
<div className="overflow-x-auto my-6">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plano</th>
|
||||
<th>Valor Mensal</th>
|
||||
<th>Descrição</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Starter</strong></td>
|
||||
<td>R$ 97,00</td>
|
||||
<td>Funcionalidades essenciais para advogados autônomos</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Pro</strong></td>
|
||||
<td>R$ 197,00</td>
|
||||
<td>Recursos avançados para escritórios em crescimento</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Enterprise</strong></td>
|
||||
<td>R$ 497,00</td>
|
||||
<td>Solução completa para grandes escritórios e departamentos jurídicos</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul>
|
||||
<li>As cobranças são <strong>recorrentes e mensais</strong>, processadas automaticamente via <strong>Stripe</strong>;</li>
|
||||
<li>Os valores podem ser alterados mediante aviso prévio de 30 (trinta) dias;</li>
|
||||
<li>A falta de pagamento poderá resultar em suspensão ou limitação do acesso;</li>
|
||||
<li>Todos os valores são em Reais (BRL) e incluem os tributos aplicáveis.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="uso" title="5. Uso Aceitável">
|
||||
<p>Ao utilizar a plataforma, você se compromete a NÃO:</p>
|
||||
<ul>
|
||||
<li>Utilizar o serviço para fins ilegais, fraudulentos ou que violem legislação vigente;</li>
|
||||
<li>Realizar engenharia reversa, descompilar ou tentar extrair o código-fonte da plataforma;</li>
|
||||
<li>Enviar spam, conteúdo malicioso ou violar direitos de terceiros;</li>
|
||||
<li>Compartilhar credenciais de acesso com terceiros não autorizados;</li>
|
||||
<li>Sobrecarregar a infraestrutura da plataforma com uso abusivo ou automatizado;</li>
|
||||
<li>Utilizar a plataforma para criar conteúdo que viole direitos autorais ou a ética profissional da OAB;</li>
|
||||
<li>Copiar, reproduzir ou redistribuir funcionalidades da plataforma sem autorização expressa.</li>
|
||||
</ul>
|
||||
<p>A violação destas regras poderá resultar em suspensão imediata da conta, sem direito a reembolso.</p>
|
||||
</Section>
|
||||
|
||||
<Section id="propriedade" title="6. Propriedade Intelectual">
|
||||
<p>
|
||||
Todos os direitos de propriedade intelectual da plataforma LexMind, incluindo software, design, logotipos, textos institucionais, algoritmos e modelos de IA, pertencem exclusivamente à LexMind Tecnologia Jurídica Ltda.
|
||||
</p>
|
||||
<p>
|
||||
Os documentos e peças jurídicas gerados pela plataforma a partir dos dados fornecidos pelo usuário são de titularidade do próprio usuário. A LexMind não reivindica propriedade sobre o conteúdo produzido pelo usuário.
|
||||
</p>
|
||||
<p>
|
||||
A LexMind poderá utilizar dados agregados e anonimizados para aprimoramento de seus serviços e modelos, conforme disposto na Política de Privacidade.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="limitacao" title="7. Limitação de Responsabilidade">
|
||||
<div className="p-5 rounded-xl bg-amber-500/10 border border-amber-500/20 my-6">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-6 h-6 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-amber-300 font-semibold mb-2">Aviso Importante</p>
|
||||
<p className="text-amber-200/80 text-sm leading-relaxed">
|
||||
A LexMind é uma <strong>ferramenta auxiliar</strong> e <strong>NÃO substitui</strong> o trabalho de um advogado devidamente inscrito na OAB. Todos os documentos gerados pela plataforma devem ser <strong>obrigatoriamente revisados por profissional habilitado</strong> antes de uso.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>A LexMind <strong>não se responsabiliza</strong> por decisões jurídicas tomadas com base exclusiva no conteúdo gerado pela plataforma;</li>
|
||||
<li>Os documentos gerados são sugestões e rascunhos que requerem revisão profissional;</li>
|
||||
<li>A LexMind não garante resultados específicos em processos judiciais ou administrativos;</li>
|
||||
<li>Em nenhuma hipótese a responsabilidade da LexMind excederá o valor total pago pelo usuário nos últimos 12 (doze) meses;</li>
|
||||
<li>A LexMind não se responsabiliza por danos indiretos, incidentais, consequenciais ou punitivos.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="disponibilidade" title="8. Disponibilidade do Serviço">
|
||||
<p>
|
||||
A LexMind emprega seus melhores esforços para manter a plataforma disponível 24 horas por dia, 7 dias por semana. Contudo, o serviço poderá ser temporariamente interrompido para:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Manutenções programadas (com aviso prévio sempre que possível);</li>
|
||||
<li>Atualizações de segurança e melhorias no sistema;</li>
|
||||
<li>Situações de força maior ou caso fortuito;</li>
|
||||
<li>Instabilidades nos serviços de terceiros dos quais a plataforma depende.</li>
|
||||
</ul>
|
||||
<p>
|
||||
A LexMind não será responsável por eventuais prejuízos decorrentes de indisponibilidade temporária.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="cancelamento" title="9. Cancelamento e Reembolso">
|
||||
<ul>
|
||||
<li>O cancelamento da assinatura pode ser solicitado a qualquer momento pelo painel do usuário;</li>
|
||||
<li>Após o cancelamento, o acesso aos recursos do plano será mantido até o final do período já pago;</li>
|
||||
<li>Não há reembolso proporcional para cancelamentos realizados durante o período de faturamento;</li>
|
||||
<li>A LexMind poderá oferecer reembolso integral em até 7 (sete) dias após a primeira contratação, conforme o Código de Defesa do Consumidor (Art. 49);</li>
|
||||
<li>Dados do usuário serão mantidos por 90 (noventa) dias após o cancelamento, podendo ser exportados nesse período.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="modificacoes" title="10. Modificações dos Termos">
|
||||
<p>
|
||||
A LexMind reserva-se o direito de alterar estes Termos a qualquer momento. As alterações serão comunicadas por:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Notificação por e-mail cadastrado;</li>
|
||||
<li>Aviso destacado na plataforma;</li>
|
||||
<li>Atualização da data de “Última atualização” nesta página.</li>
|
||||
</ul>
|
||||
<p>
|
||||
O uso continuado da plataforma após a notificação de alterações constitui aceitação dos novos termos. Em caso de discordância, o usuário deverá cessar o uso e solicitar o cancelamento.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="lei" title="11. Lei Aplicável e Foro">
|
||||
<p>
|
||||
Estes Termos de Uso são regidos pela legislação da República Federativa do Brasil, incluindo, mas não se limitando a:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Lei nº 12.965/2014 (Marco Civil da Internet);</li>
|
||||
<li>Lei nº 13.709/2018 (Lei Geral de Proteção de Dados — LGPD);</li>
|
||||
<li>Lei nº 8.078/1990 (Código de Defesa do Consumidor).</li>
|
||||
</ul>
|
||||
<p>
|
||||
Fica eleito o foro da Comarca de <strong>São Paulo/SP</strong> para dirimir quaisquer controvérsias decorrentes destes Termos, com renúncia expressa a qualquer outro, por mais privilegiado que seja.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="contato" title="12. Contato">
|
||||
<p>Para dúvidas, sugestões ou solicitações relacionadas a estes Termos, entre em contato:</p>
|
||||
<div className="p-5 rounded-xl bg-white/[0.03] border border-white/[0.06] my-4">
|
||||
<ul className="space-y-2 list-none pl-0">
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Empresa:</span>
|
||||
<span className="text-white">LexMind Tecnologia Jurídica Ltda.</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">E-mail:</span>
|
||||
<a href="mailto:contato@lexmind.adv.br" className="text-teal-400 hover:text-teal-300">contato@lexmind.adv.br</a>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<span className="text-gray-500">Website:</span>
|
||||
<a href="https://lexmind.adv.br" className="text-teal-400 hover:text-teal-300">lexmind.adv.br</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section id={id} className="mb-12 scroll-mt-24">
|
||||
<h2 className="text-2xl font-bold text-white mb-4 pb-2 border-b border-white/[0.06]">{title}</h2>
|
||||
<div className="text-gray-400 leading-relaxed space-y-4 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:space-y-2 [&_li]:text-gray-400 [&_strong]:text-white [&_table]:w-full [&_table]:text-sm [&_th]:text-left [&_th]:text-gray-300 [&_th]:font-semibold [&_th]:pb-3 [&_th]:border-b [&_th]:border-white/10 [&_td]:py-3 [&_td]:text-gray-400 [&_td]:border-b [&_td]:border-white/[0.04] [&_tr:last-child_td]:border-0">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
290
src/components/FileUpload.tsx
Normal file
290
src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Upload, X, FileText, File, Loader2, Trash2, Download,
|
||||
AlertCircle, CheckCircle2, CloudUpload
|
||||
} from 'lucide-react'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
filename: string
|
||||
size: number
|
||||
mimeType: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
onFilesChange?: (files: UploadedFile[]) => void
|
||||
maxFiles?: number
|
||||
label?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.doc', '.txt']
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain',
|
||||
]
|
||||
const MAX_SIZE = 50 * 1024 * 1024
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string) {
|
||||
if (mimeType === 'application/pdf') return '📄'
|
||||
if (mimeType.includes('word')) return '📝'
|
||||
if (mimeType === 'text/plain') return '📃'
|
||||
return '📎'
|
||||
}
|
||||
|
||||
export default function FileUpload({
|
||||
onFilesChange,
|
||||
maxFiles = 10,
|
||||
label = 'Documentos de Referência',
|
||||
compact = false,
|
||||
}: FileUploadProps) {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load existing uploads on mount
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
onFilesChange?.(files)
|
||||
}, [files, onFilesChange])
|
||||
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/uploads?limit=50')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setFiles(data.uploads || [])
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ext || !ALLOWED_EXTENSIONS.includes('.' + ext)) {
|
||||
return `Tipo não permitido: ${file.name}. Aceitos: PDF, DOCX, DOC, TXT`
|
||||
}
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return `Arquivo muito grande: ${file.name} (${formatSize(file.size)}). Máx: 50MB`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
setError(null)
|
||||
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length >= maxFiles) {
|
||||
setError(`Máximo de ${maxFiles} arquivos`)
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(`Enviando ${file.name}...`)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Erro ao fazer upload')
|
||||
}
|
||||
|
||||
setFiles(prev => [data.upload, ...prev])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao fazer upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(null)
|
||||
}
|
||||
}, [files.length, maxFiles])
|
||||
|
||||
const handleFiles = useCallback((fileList: FileList | null) => {
|
||||
if (!fileList) return
|
||||
Array.from(fileList).forEach(f => uploadFile(f))
|
||||
}, [uploadFile])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeleting(id)
|
||||
try {
|
||||
const res = await fetch(`/api/uploads/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setFiles(prev => prev.filter(f => f.id !== id))
|
||||
}
|
||||
} catch {
|
||||
setError('Erro ao excluir arquivo')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = (id: string) => {
|
||||
window.open(`/api/uploads/${id}`, '_blank')
|
||||
}
|
||||
|
||||
// Drag & drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.currentTarget === dropRef.current) {
|
||||
setDragOver(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOver(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl transition-all duration-200 cursor-pointer
|
||||
${compact ? 'p-4' : 'p-6'}
|
||||
${dragOver
|
||||
? 'border-teal-400 bg-teal-500/10 shadow-lg shadow-teal-500/10'
|
||||
: 'border-white/10 hover:border-teal-500/30 hover:bg-white/[0.02]'
|
||||
}
|
||||
${uploading ? 'pointer-events-none opacity-60' : ''}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.docx,.doc,.txt"
|
||||
className="hidden"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
|
||||
<p className="text-sm text-teal-300">{uploadProgress}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CloudUpload className={`${compact ? 'w-6 h-6' : 'w-8 h-8'} ${dragOver ? 'text-teal-400' : 'text-white/20'}`} />
|
||||
<div>
|
||||
<p className={`${compact ? 'text-xs' : 'text-sm'} text-white/40`}>
|
||||
{dragOver ? (
|
||||
<span className="text-teal-300 font-medium">Solte o arquivo aqui</span>
|
||||
) : (
|
||||
<>Arraste arquivos ou <span className="text-teal-400 font-medium">clique para upload</span></>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-white/20 mt-1">PDF, DOCX, DOC, TXT · Máx 50MB</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-sm text-red-400">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 p-3 rounded-xl border border-white/5 bg-white/[0.02] group hover:bg-white/[0.04] transition-all"
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">{getFileIcon(file.mimeType)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{file.filename}</p>
|
||||
<p className="text-xs text-white/30">{formatSize(file.size)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDownload(file.id) }}
|
||||
className="p-1.5 rounded-lg text-white/40 hover:text-teal-400 hover:bg-teal-500/10 transition-all"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(file.id) }}
|
||||
disabled={deleting === file.id}
|
||||
className="p-1.5 rounded-lg text-white/40 hover:text-red-400 hover:bg-red-500/10 transition-all disabled:opacity-50"
|
||||
title="Excluir"
|
||||
>
|
||||
{deleting === file.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/components/Footer.tsx
Normal file
104
src/components/Footer.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import Link from "next/link"
|
||||
import { Logo } from "./Logo"
|
||||
|
||||
const productLinks = [
|
||||
{ label: "Redação de Peças", href: "/pecas" },
|
||||
{ label: "Pesquisa Inteligente", href: "/jurisprudencia" },
|
||||
{ label: "Consultor IA", href: "/chat" },
|
||||
{ label: "Acervo de Modelos", href: "/templates" },
|
||||
]
|
||||
|
||||
const resourceLinks = [
|
||||
{ label: "Investimento", href: "/pricing" },
|
||||
{ label: "Artigos", href: "/blog" },
|
||||
{ label: "Suporte", href: "/help" },
|
||||
{ label: "Disponibilidade", href: "/status" },
|
||||
]
|
||||
|
||||
const legalLinks = [
|
||||
{ label: "Termos de Serviço", href: "/termos" },
|
||||
{ label: "Política de Privacidade", href: "/privacidade" },
|
||||
{ label: "Conformidade LGPD", href: "/lgpd" },
|
||||
]
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-white/[0.06] bg-[#060a12]">
|
||||
<div className="max-w-6xl mx-auto px-6 py-16">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-10">
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<Link href="/" className="inline-block mb-5">
|
||||
<Logo size="sm" />
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500 leading-relaxed max-w-[240px]">
|
||||
Tecnologia de ponta a serviço da advocacia brasileira. Precisão, agilidade e conformidade em cada documento.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white mb-5">Soluções</h4>
|
||||
<ul className="space-y-3">
|
||||
{productLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white mb-5">Institucional</h4>
|
||||
<ul className="space-y-3">
|
||||
{resourceLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-white mb-5">Jurídico</h4>
|
||||
<ul className="space-y-3">
|
||||
{legalLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-gray-500 hover:text-teal-300 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 pt-8 border-t border-white/[0.06] flex flex-col items-center gap-4">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between w-full gap-4">
|
||||
<p className="text-xs text-gray-600">
|
||||
© {new Date().getFullYear()} LexMind Tecnologia Jurídica Ltda. Todos os direitos reservados.
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">Desenvolvido com propósito para o Direito brasileiro</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-40 hover:opacity-70 transition-opacity duration-300 mt-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-teal-400">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
|
||||
</svg>
|
||||
<span className="text-[0.65rem] text-teal-400/60 tracking-[0.15em] font-semibold">KISLANSKI INDUSTRIES</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
34
src/components/Logo.tsx
Normal file
34
src/components/Logo.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showText?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Logo({ size = 'md', showText = true, className }: LogoProps) {
|
||||
const sizes = {
|
||||
sm: { icon: 28, text: 'text-base' },
|
||||
md: { icon: 36, text: 'text-lg' },
|
||||
lg: { icon: 48, text: 'text-2xl' },
|
||||
}
|
||||
|
||||
const s = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2.5', className)}>
|
||||
<Image src="/logo.svg" alt="LexMind" width={s.icon} height={s.icon} className="shrink-0" />
|
||||
{showText && (
|
||||
<span className={cn('font-bold tracking-tight', s.text)}>
|
||||
<span className="text-white">Lex</span>
|
||||
<span className="bg-gradient-to-r from-teal-400 to-cyan-400 bg-clip-text text-transparent">Mind</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogoIcon({ size = 'md', className }: Omit<LogoProps, 'showText'>) {
|
||||
return <Logo size={size} showText={false} className={className} />
|
||||
}
|
||||
125
src/components/Navbar.tsx
Normal file
125
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
const navLinks = [
|
||||
{ label: 'Recursos', href: '#features' },
|
||||
{ label: 'Metodologia', href: '#how-it-works' },
|
||||
{ label: 'Depoimentos', href: '#testimonials' },
|
||||
{ label: 'Investimento', href: '#pricing' },
|
||||
{ label: 'Dúvidas', href: '#faq' },
|
||||
]
|
||||
|
||||
export default function Navbar() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setScrolled(window.scrollY > 20)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-[#0a0f1a]/90 backdrop-blur-xl border-b border-white/[0.06] shadow-lg shadow-black/20'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/" className="relative z-10">
|
||||
<Logo size="sm" />
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{navLinks.map((link, i) => (
|
||||
<motion.a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.05 }}
|
||||
className="text-sm text-gray-400 hover:text-teal-300 transition-colors duration-200"
|
||||
>
|
||||
{link.label}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-gray-400 hover:text-white transition-colors px-4 py-2 rounded-lg hover:bg-white/[0.04]"
|
||||
>
|
||||
Acessar
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-sm px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-600 to-teal-500 hover:from-teal-500 hover:to-teal-400 text-white font-medium transition-all duration-200 shadow-md shadow-teal-600/20"
|
||||
>
|
||||
Iniciar Agora
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="md:hidden relative z-10 p-2 text-gray-400 hover:text-white transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
{open ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="md:hidden overflow-hidden border-t border-white/[0.06] bg-[#0a0f1a]/98 backdrop-blur-xl"
|
||||
>
|
||||
<div className="px-6 py-4 space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-3 text-sm text-gray-400 hover:text-white rounded-lg hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<hr className="border-white/[0.06] my-3" />
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-3 text-sm text-gray-400 hover:text-white"
|
||||
>
|
||||
Acessar
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-3 text-sm font-medium text-center rounded-lg bg-gradient-to-r from-teal-600 to-teal-500 text-white mt-2"
|
||||
>
|
||||
Iniciar Agora
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
)
|
||||
}
|
||||
11
src/components/Providers.tsx
Normal file
11
src/components/Providers.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Providers({ children }: ProvidersProps) {
|
||||
return <SessionProvider basePath="/adv/api/auth">{children}</SessionProvider>
|
||||
}
|
||||
125
src/lib/auth.ts
Normal file
125
src/lib/auth.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextAuthOptions } from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import { prisma } from './prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
plan: string
|
||||
credits: number
|
||||
oabNumber?: string | null
|
||||
oabState?: string | null
|
||||
avatar?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
plan: string
|
||||
credits: number
|
||||
oabNumber?: string | null
|
||||
oabState?: string | null
|
||||
avatar?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string
|
||||
role: string
|
||||
plan: string
|
||||
credits: number
|
||||
oabNumber?: string | null
|
||||
oabState?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Senha', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error('Email e senha são obrigatórios')
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Usuário não encontrado')
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(credentials.password, user.password)
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error('Senha incorreta')
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
plan: user.plan,
|
||||
credits: user.credits,
|
||||
oabNumber: user.oabNumber,
|
||||
oabState: user.oabState,
|
||||
avatar: user.avatar,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
token.role = user.role
|
||||
token.plan = user.plan
|
||||
token.credits = user.credits
|
||||
token.oabNumber = user.oabNumber
|
||||
token.oabState = user.oabState
|
||||
}
|
||||
|
||||
// Allow session updates (e.g., after plan change)
|
||||
if (trigger === 'update' && session) {
|
||||
token.plan = session.plan ?? token.plan
|
||||
token.credits = session.credits ?? token.credits
|
||||
token.role = session.role ?? token.role
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.id
|
||||
session.user.role = token.role
|
||||
session.user.plan = token.plan
|
||||
session.user.credits = token.credits
|
||||
session.user.oabNumber = token.oabNumber
|
||||
session.user.oabState = token.oabState
|
||||
return session
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
error: '/login',
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
}
|
||||
453
src/lib/diarios-service.ts
Normal file
453
src/lib/diarios-service.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
// Service para Integração com Diários Oficiais (DataJud CNJ)
|
||||
|
||||
import { calcularPrazo } from './publicacoes-service'
|
||||
|
||||
// API Key pública do DataJud (CNJ)
|
||||
const DATAJUD_API_KEY = 'cDZHYzlZa0JadVREZDJCendQbXY6SkJlTzNjLV9TRENyQk1RdnFKZGRQdw=='
|
||||
const DATAJUD_BASE_URL = 'https://api-publica.datajud.cnj.jus.br'
|
||||
|
||||
// Mapeamento de tribunais para endpoints da API
|
||||
const TRIBUNAL_ENDPOINTS: Record<string, string> = {
|
||||
TJSP: 'api_publica_tjsp',
|
||||
TJRJ: 'api_publica_tjrj',
|
||||
TJMG: 'api_publica_tjmg',
|
||||
TJRS: 'api_publica_tjrs',
|
||||
TJPR: 'api_publica_tjpr',
|
||||
TJSC: 'api_publica_tjsc',
|
||||
TJBA: 'api_publica_tjba',
|
||||
TJPE: 'api_publica_tjpe',
|
||||
TJCE: 'api_publica_tjce',
|
||||
TJDF: 'api_publica_tjdftj',
|
||||
TJGO: 'api_publica_tjgo',
|
||||
TJMA: 'api_publica_tjma',
|
||||
TJPA: 'api_publica_tjpa',
|
||||
TJMT: 'api_publica_tjmt',
|
||||
TJMS: 'api_publica_tjms',
|
||||
TJES: 'api_publica_tjes',
|
||||
TJAL: 'api_publica_tjal',
|
||||
TJAM: 'api_publica_tjam',
|
||||
TJPB: 'api_publica_tjpb',
|
||||
TJPI: 'api_publica_tjpi',
|
||||
TJRN: 'api_publica_tjrn',
|
||||
TJSE: 'api_publica_tjse',
|
||||
TJTO: 'api_publica_tjto',
|
||||
TJAC: 'api_publica_tjac',
|
||||
TJAP: 'api_publica_tjap',
|
||||
TJRO: 'api_publica_tjro',
|
||||
TJRR: 'api_publica_tjrr',
|
||||
TRF1: 'api_publica_trf1',
|
||||
TRF2: 'api_publica_trf2',
|
||||
TRF3: 'api_publica_trf3',
|
||||
TRF4: 'api_publica_trf4',
|
||||
TRF5: 'api_publica_trf5',
|
||||
TRF6: 'api_publica_trf6',
|
||||
STJ: 'api_publica_stj',
|
||||
STF: 'api_publica_stf',
|
||||
TST: 'api_publica_tst',
|
||||
}
|
||||
|
||||
// Códigos de movimentos relevantes (tabela unificada CNJ)
|
||||
const CODIGOS_PUBLICACAO = [92] // Publicação
|
||||
const CODIGOS_INTIMACAO = [12265, 12021, 12022] // Intimação, Intimação eletrônica, etc
|
||||
const CODIGOS_CITACAO = [14, 12037] // Citação
|
||||
const CODIGOS_SENTENCA = [22, 848, 385] // Sentença, Sentença estrangeira homologada
|
||||
const CODIGOS_ACORDAO = [217, 219] // Acórdão
|
||||
const CODIGOS_DESPACHO = [11010, 11383] // Despacho, Ato ordinatório
|
||||
|
||||
// Keywords para detectar tipo de publicação pelo texto
|
||||
const KEYWORDS_TIPO: Record<string, RegExp> = {
|
||||
INTIMACAO: /\b(intima[çc][aã]o|fica\s+intimad[oa]|intim[ea][\-\s]se)\b/i,
|
||||
CITACAO: /\b(cita[çc][aã]o|fica\s+citad[oa]|cit[ea][\-\s]se)\b/i,
|
||||
SENTENCA: /\b(senten[çc]a|julgo\s+procedente|julgo\s+improcedente|julgo\s+(parcialmente\s+)?procedente)\b/i,
|
||||
ACORDAO: /\b(ac[oó]rd[aã]o|acordam\s+os\s+desembargadores|acordam\s+os\s+ministros)\b/i,
|
||||
DESPACHO: /\b(despacho|determino|defiro|indefiro|mero\s+expediente|ato\s+ordinat[oó]rio)\b/i,
|
||||
}
|
||||
|
||||
export interface PublicacaoEncontrada {
|
||||
dataPublicacao: Date
|
||||
diario: string
|
||||
conteudo: string
|
||||
tipo: 'INTIMACAO' | 'CITACAO' | 'SENTENCA' | 'DESPACHO' | 'ACORDAO' | 'OUTROS'
|
||||
prazoCalculado: Date
|
||||
prazoTipo: string
|
||||
fonte: 'DATAJUD' | 'DJE_TJSP' | 'ESAJ'
|
||||
codigoMovimento?: number
|
||||
}
|
||||
|
||||
export interface ProcessoBuscado {
|
||||
id: string
|
||||
numeroProcesso: string
|
||||
tribunal: string
|
||||
}
|
||||
|
||||
export interface ResultadoBusca {
|
||||
sucesso: boolean
|
||||
publicacoes: PublicacaoEncontrada[]
|
||||
erro?: string
|
||||
fonte: string
|
||||
}
|
||||
|
||||
// ===== TIPOS PARA PROCESSO COMPLETO =====
|
||||
|
||||
export interface MovimentoCompleto {
|
||||
codigo: number
|
||||
nome: string
|
||||
dataHora: Date
|
||||
complementosTabelados?: Array<{ codigo: number; nome: string }>
|
||||
}
|
||||
|
||||
export interface ProcessoCompleto {
|
||||
numeroProcesso: string
|
||||
classe?: { codigo: number; nome: string }
|
||||
sistema?: { codigo: number; nome: string }
|
||||
formato?: { codigo: number; nome: string }
|
||||
tribunal: string
|
||||
dataAjuizamento?: Date
|
||||
grau?: string
|
||||
nivelSigilo?: number
|
||||
orgaoJulgador?: { codigo: number; nome: string }
|
||||
assuntos?: Array<{ codigo: number; nome: string }>
|
||||
movimentos?: MovimentoCompleto[]
|
||||
dadosBrutos?: any
|
||||
}
|
||||
|
||||
export interface ResultadoProcessoCompleto {
|
||||
sucesso: boolean
|
||||
dados?: ProcessoCompleto
|
||||
erro?: string
|
||||
}
|
||||
|
||||
// Remove formatação do número do processo (só dígitos)
|
||||
function limparNumeroProcesso(numero: string): string {
|
||||
return numero.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
// Detecta tipo de publicação baseado no código ou keywords
|
||||
function detectarTipo(codigoMovimento: number, nomeMovimento: string): PublicacaoEncontrada['tipo'] {
|
||||
// Primeiro, verificar pelo código
|
||||
if (CODIGOS_INTIMACAO.includes(codigoMovimento)) return 'INTIMACAO'
|
||||
if (CODIGOS_CITACAO.includes(codigoMovimento)) return 'CITACAO'
|
||||
if (CODIGOS_SENTENCA.includes(codigoMovimento)) return 'SENTENCA'
|
||||
if (CODIGOS_ACORDAO.includes(codigoMovimento)) return 'ACORDAO'
|
||||
if (CODIGOS_DESPACHO.includes(codigoMovimento)) return 'DESPACHO'
|
||||
|
||||
// Se for publicação genérica, tentar detectar pelo nome
|
||||
if (CODIGOS_PUBLICACAO.includes(codigoMovimento)) {
|
||||
for (const [tipo, regex] of Object.entries(KEYWORDS_TIPO)) {
|
||||
if (regex.test(nomeMovimento)) {
|
||||
return tipo as PublicacaoEncontrada['tipo']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'OUTROS'
|
||||
}
|
||||
|
||||
// ===== BUSCA PROCESSO COMPLETO NA API DATAJUD =====
|
||||
export async function buscarProcessoCompleto(
|
||||
numeroProcesso: string,
|
||||
tribunal: string
|
||||
): Promise<ResultadoProcessoCompleto> {
|
||||
const endpoint = TRIBUNAL_ENDPOINTS[tribunal.toUpperCase()]
|
||||
|
||||
if (!endpoint) {
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: `Tribunal ${tribunal} não suportado na API DataJud`,
|
||||
}
|
||||
}
|
||||
|
||||
const numeroLimpo = limparNumeroProcesso(numeroProcesso)
|
||||
const url = `${DATAJUD_BASE_URL}/${endpoint}/_search`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: 1,
|
||||
query: {
|
||||
match: {
|
||||
numeroProcesso: numeroLimpo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[DataJud] Erro HTTP ${response.status}:`, errorText)
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: `HTTP ${response.status}: ${errorText.substring(0, 200)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const hits = data.hits?.hits || []
|
||||
|
||||
if (hits.length === 0) {
|
||||
return {
|
||||
sucesso: true,
|
||||
dados: undefined,
|
||||
erro: 'Processo não encontrado na API DataJud',
|
||||
}
|
||||
}
|
||||
|
||||
const processo = hits[0]._source
|
||||
|
||||
// Mapear movimentos com todos os dados
|
||||
const movimentos: MovimentoCompleto[] = (processo.movimentos || []).map((m: any) => ({
|
||||
codigo: m.codigo,
|
||||
nome: m.nome || 'Movimentação sem nome',
|
||||
dataHora: new Date(m.dataHora),
|
||||
complementosTabelados: m.complementosTabelados || [],
|
||||
}))
|
||||
|
||||
// Ordenar movimentos por data (mais recente primeiro)
|
||||
movimentos.sort((a, b) => b.dataHora.getTime() - a.dataHora.getTime())
|
||||
|
||||
const dadosCompletos: ProcessoCompleto = {
|
||||
numeroProcesso: processo.numeroProcesso,
|
||||
classe: processo.classe,
|
||||
sistema: processo.sistema,
|
||||
formato: processo.formato,
|
||||
tribunal: tribunal,
|
||||
dataAjuizamento: processo.dataAjuizamento ? new Date(processo.dataAjuizamento) : undefined,
|
||||
grau: processo.grau,
|
||||
nivelSigilo: processo.nivelSigilo,
|
||||
orgaoJulgador: processo.orgaoJulgador,
|
||||
assuntos: processo.assuntos || [],
|
||||
movimentos,
|
||||
dadosBrutos: processo,
|
||||
}
|
||||
|
||||
console.log(`[DataJud] Processo ${numeroProcesso}: ${movimentos.length} movimentos encontrados`)
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
dados: dadosCompletos,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DataJud] Erro ao buscar ${numeroProcesso}:`, error)
|
||||
return {
|
||||
sucesso: false,
|
||||
erro: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Busca processo na API DataJud (versão que filtra publicações)
|
||||
export async function buscarDataJud(
|
||||
numeroProcesso: string,
|
||||
tribunal: string
|
||||
): Promise<ResultadoBusca> {
|
||||
const endpoint = TRIBUNAL_ENDPOINTS[tribunal.toUpperCase()]
|
||||
|
||||
if (!endpoint) {
|
||||
return {
|
||||
sucesso: false,
|
||||
publicacoes: [],
|
||||
erro: `Tribunal ${tribunal} não suportado na API DataJud`,
|
||||
fonte: 'DATAJUD',
|
||||
}
|
||||
}
|
||||
|
||||
const numeroLimpo = limparNumeroProcesso(numeroProcesso)
|
||||
const url = `${DATAJUD_BASE_URL}/${endpoint}/_search`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `APIKey ${DATAJUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
size: 1,
|
||||
query: {
|
||||
match: {
|
||||
numeroProcesso: numeroLimpo,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`[DataJud] Erro HTTP ${response.status}:`, errorText)
|
||||
return {
|
||||
sucesso: false,
|
||||
publicacoes: [],
|
||||
erro: `HTTP ${response.status}: ${errorText.substring(0, 200)}`,
|
||||
fonte: 'DATAJUD',
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const hits = data.hits?.hits || []
|
||||
|
||||
if (hits.length === 0) {
|
||||
return {
|
||||
sucesso: true,
|
||||
publicacoes: [],
|
||||
fonte: 'DATAJUD',
|
||||
}
|
||||
}
|
||||
|
||||
const processo = hits[0]._source
|
||||
const movimentos = processo.movimentos || []
|
||||
|
||||
// Filtrar movimentos relevantes (Publicação, Intimação, Citação, Sentença, Despacho)
|
||||
const codigosRelevantes = [
|
||||
...CODIGOS_PUBLICACAO,
|
||||
...CODIGOS_INTIMACAO,
|
||||
...CODIGOS_CITACAO,
|
||||
...CODIGOS_SENTENCA,
|
||||
...CODIGOS_ACORDAO,
|
||||
...CODIGOS_DESPACHO,
|
||||
]
|
||||
|
||||
const movimentosRelevantes = movimentos.filter((m: any) =>
|
||||
codigosRelevantes.includes(m.codigo) ||
|
||||
m.nome?.toLowerCase().includes('publicação') ||
|
||||
m.nome?.toLowerCase().includes('intimação') ||
|
||||
m.nome?.toLowerCase().includes('citação') ||
|
||||
m.nome?.toLowerCase().includes('sentença')
|
||||
)
|
||||
|
||||
const publicacoes: PublicacaoEncontrada[] = movimentosRelevantes.map((mov: any) => {
|
||||
const dataPublicacao = new Date(mov.dataHora)
|
||||
const tipo = detectarTipo(mov.codigo, mov.nome || '')
|
||||
const { prazoCalculado, prazoTipo } = calcularPrazo(dataPublicacao, tipo)
|
||||
|
||||
// Construir conteúdo descritivo
|
||||
const complementos = mov.complementosTabelados?.map((c: any) => c.nome).join(', ') || ''
|
||||
const orgao = mov.orgaoJulgador?.nome || ''
|
||||
|
||||
const conteudo = [
|
||||
`${mov.nome || 'Movimentação'}`,
|
||||
processo.classe?.nome ? `Classe: ${processo.classe.nome}` : '',
|
||||
complementos ? `Complemento: ${complementos}` : '',
|
||||
orgao ? `Órgão: ${orgao}` : '',
|
||||
`Processo: ${numeroProcesso}`,
|
||||
`Tribunal: ${tribunal}`,
|
||||
].filter(Boolean).join('. ')
|
||||
|
||||
return {
|
||||
dataPublicacao,
|
||||
diario: 'DJe',
|
||||
conteudo,
|
||||
tipo,
|
||||
prazoCalculado,
|
||||
prazoTipo,
|
||||
fonte: 'DATAJUD' as const,
|
||||
codigoMovimento: mov.codigo,
|
||||
}
|
||||
})
|
||||
|
||||
// Ordenar por data decrescente
|
||||
publicacoes.sort((a, b) => b.dataPublicacao.getTime() - a.dataPublicacao.getTime())
|
||||
|
||||
console.log(`[DataJud] Processo ${numeroProcesso}: ${publicacoes.length} publicações encontradas`)
|
||||
|
||||
return {
|
||||
sucesso: true,
|
||||
publicacoes,
|
||||
fonte: 'DATAJUD',
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[DataJud] Erro ao buscar ${numeroProcesso}:`, error)
|
||||
return {
|
||||
sucesso: false,
|
||||
publicacoes: [],
|
||||
erro: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||
fonte: 'DATAJUD',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Busca no DJe TJSP via scraping (backup)
|
||||
export async function buscarDJeTJSP(numeroProcesso: string): Promise<ResultadoBusca> {
|
||||
// TODO: Implementar scraping do DJe TJSP como backup
|
||||
// Por enquanto, retorna vazio - pode ser implementado com puppeteer/cheerio
|
||||
console.log(`[DJe TJSP] Scraping não implementado para ${numeroProcesso}`)
|
||||
return {
|
||||
sucesso: true,
|
||||
publicacoes: [],
|
||||
fonte: 'DJE_TJSP',
|
||||
}
|
||||
}
|
||||
|
||||
// Função principal que tenta todas as fontes
|
||||
export async function buscarPublicacoesReais(
|
||||
processo: ProcessoBuscado,
|
||||
diasAtras: number = 30
|
||||
): Promise<ResultadoBusca> {
|
||||
console.log(`[Diarios] Buscando publicações para ${processo.numeroProcesso} (${processo.tribunal})`)
|
||||
|
||||
// 1. Tentar DataJud primeiro (fonte principal)
|
||||
const resultadoDataJud = await buscarDataJud(processo.numeroProcesso, processo.tribunal)
|
||||
|
||||
if (resultadoDataJud.sucesso && resultadoDataJud.publicacoes.length > 0) {
|
||||
// Filtrar apenas publicações dos últimos N dias
|
||||
const dataLimite = new Date()
|
||||
dataLimite.setDate(dataLimite.getDate() - diasAtras)
|
||||
|
||||
const publicacoesRecentes = resultadoDataJud.publicacoes.filter(
|
||||
pub => pub.dataPublicacao >= dataLimite
|
||||
)
|
||||
|
||||
return {
|
||||
...resultadoDataJud,
|
||||
publicacoes: publicacoesRecentes,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Se DataJud falhou ou não encontrou, tentar DJe TJSP (se for TJSP)
|
||||
if (processo.tribunal.toUpperCase() === 'TJSP') {
|
||||
const resultadoDJe = await buscarDJeTJSP(processo.numeroProcesso)
|
||||
if (resultadoDJe.sucesso && resultadoDJe.publicacoes.length > 0) {
|
||||
return resultadoDJe
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Retornar resultado (mesmo vazio)
|
||||
return resultadoDataJud
|
||||
}
|
||||
|
||||
// Busca em lote com rate limiting
|
||||
export async function buscarPublicacoesEmLote(
|
||||
processos: ProcessoBuscado[],
|
||||
delayMs: number = 500
|
||||
): Promise<Map<string, ResultadoBusca>> {
|
||||
const resultados = new Map<string, ResultadoBusca>()
|
||||
|
||||
for (let i = 0; i < processos.length; i++) {
|
||||
const processo = processos[i]
|
||||
console.log(`[Diarios] Processando ${i + 1}/${processos.length}: ${processo.numeroProcesso}`)
|
||||
|
||||
try {
|
||||
const resultado = await buscarPublicacoesReais(processo)
|
||||
resultados.set(processo.id, resultado)
|
||||
} catch (error) {
|
||||
console.error(`[Diarios] Erro no processo ${processo.numeroProcesso}:`, error)
|
||||
resultados.set(processo.id, {
|
||||
sucesso: false,
|
||||
publicacoes: [],
|
||||
erro: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||
fonte: 'DATAJUD',
|
||||
})
|
||||
}
|
||||
|
||||
// Rate limiting entre requisições
|
||||
if (i < processos.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
|
||||
return resultados
|
||||
}
|
||||
15
src/lib/prisma.ts
Normal file
15
src/lib/prisma.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
export default prisma
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user