52 Commits

Author SHA1 Message Date
Vitex Tecnologia 2ff508f471 permitir o usuario a ver o fechamento com o status em aberto 2026-06-05 16:38:24 -03:00
Vitex Tecnologia 521ca9743f ajuste responsividade modal de alterar competencia da tarefa 2026-06-05 13:46:40 -03:00
Vitex Tecnologia 8a77c0dd31 implementacao de logs de auditoria e alterar multiplos registros de competencia 2026-06-01 17:19:00 -03:00
Vitex Tecnologia 34ae317fed Modulo Commander CSV 2026-05-28 11:06:46 -03:00
Vitex Tecnologia 422107331b melhoria filtro de conclusao fechamento 2026-05-22 17:58:35 -03:00
Vitex Tecnologia fc5fb3cc15 atualizacao tela de fechamento 2026-05-20 16:38:07 -03:00
Vitex Tecnologia 0b5c7350c5 atualizacoes modulo Score e Commander 2026-05-20 10:53:17 -03:00
Vitex Tecnologia 6acfee2c76 Atualizacao visual HGTX Commander 2026-05-16 19:29:25 -03:00
Vitex Tecnologia b4622c9166 atualizacoes API Integracao 2026-05-15 17:13:52 -03:00
Vitex Tecnologia 517c9751bc novas features, geracao pdf, regras de usuarios, novo perfil de acesso 2026-05-13 17:43:25 -03:00
Vitex Tecnologia 343aae602f novas atualizações na exportacao planilha, tela de listar fechamento, filtros 2026-05-07 11:34:55 -03:00
Vitex Tecnologia 1c1e62c7db atualizacao na conclusao do fechamento 2026-05-04 11:59:56 -03:00
Vitex Tecnologia 7859230468 novas atualizacoes 2026-05-04 10:51:21 -03:00
Vitex Tecnologia dee9ecfbca remover dist zip 2026-04-29 10:17:17 -03:00
Vitex Tecnologia d570029890 nova função de alterar task de competencia 2026-04-29 10:13:17 -03:00
Vitex Tecnologia b19e2963c4 ajuste env 2026-04-28 11:03:01 -03:00
Vitex Tecnologia f2cc21f282 ajustes 2026-04-27 21:49:40 -03:00
Vitex Tecnologia 4c28862c5c novas atualizacoes do sistema de fechamento 2026-04-27 16:50:27 -03:00
Vitex Tecnologia 6da067c641 atualizacoes modulo fechamento 2026-04-26 22:54:56 -03:00
Vitex Tecnologia 7a01c6f97b Atualizacoes: Novo modo leitura de Parecer, campo instrucao agora é opcional, campo do agente do parecer pode ser recolhido, ajuste na cor do botão de agente de prompt 2026-04-14 14:42:33 -03:00
Vitex Tecnologia 8f2e4f1d21 Ajustes e melhorias no Codex 2026-03-23 18:24:08 -03:00
Vitex Tecnologia 8d8818c32e nova implementacao modo chat e agente no Parecer 2026-03-20 13:12:17 -03:00
Vitex Tecnologia 6537e2dd53 atualizacoes codex 2026-03-20 00:02:12 -03:00
Vitex Tecnologia 6bf7e7d42e Nova versao codex Parecer, atualizacao completa 2026-03-18 01:07:27 -03:00
developer1054 39032ea621 Merge commit '71f9c9ab860f7c5597427fb5fc82492a925aa9cf' 2026-01-19 14:27:07 -03:00
Vitex Tecnologia 71f9c9ab86 mudanca requisicoes API do metodo PUT para POST e limpeza / organizacao dos códigos 2026-01-19 14:19:18 -03:00
developer1054 d75d80e9c7 build 2026-01-15 10:08:47 -03:00
developer1054 189a2a8f2c Merge commit '76cbd80eefee58b1335cf335ef42eaa3570c26ae' 2026-01-15 09:38:02 -03:00
developer1054 50aca767d7 remove commented-out code for bots tab in Layout component 2026-01-15 09:37:15 -03:00
Vitex Tecnologia 76cbd80eef modulo de agente pessoal de IA 2026-01-14 20:10:42 -03:00
luisfepsale ee7204982c [change/tempo de requisição do parecer] 2025-11-24 09:26:33 -03:00
luisfepsale a28b780718 [new/parecer juridico] 2025-11-19 15:19:41 -03:00
developer1054 0fa382ef0e Merge branch 'main' of https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API 2025-10-28 11:34:30 -03:00
developer1054 c2aa536660 fix redir 2025-10-28 11:34:25 -03:00
luisfepsale c4b281abd5 [Correçã de CORs nos arquivos] 2025-10-28 11:31:54 -03:00
luisfepsale 45ea53b364 [Parecer Juridico] 2025-10-28 09:56:35 -03:00
luisfepsale 9f75feb69a correções e bugs 2025-10-24 12:05:27 -03:00
luisfepsale a85465e332 Integração com o banco de dados 2025-10-23 23:27:28 -03:00
developer1054 8a9096470f Merge pull request 'develop' (#1) from develop into main
Reviewed-on: https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API/pulls/1
2025-10-23 17:02:15 -03:00
developer1054 c82c2166f2 Merge commit 'c8c2ca8c5ba14196e2e1892fffd24024e54ac8c3' into develop 2025-10-23 16:59:06 -03:00
developer1054 c8c2ca8c5b integracao-core 2025-10-23 16:58:21 -03:00
developer1054 8b5c940976 rotas 2025-10-23 15:30:48 -03:00
luisfepsale 235fb538e1 Primeiro Commit 2025-10-22 12:52:23 -03:00
gpt-engineer-app[bot] 27820254e7 Use Axios for POST request 2025-10-17 21:32:50 +00:00
gpt-engineer-app[bot] e9f0165006 Refactor Opinion Agent to use POST request 2025-10-17 21:30:37 +00:00
gpt-engineer-app[bot] c88342369f Update audio transcription icon 2025-10-17 18:51:07 +00:00
gpt-engineer-app[bot] 18285851ac Refactor audio section in sidebar 2025-10-17 18:49:40 +00:00
gpt-engineer-app[bot] b1b37f5cf1 Refactor: Make UI responsive 2025-10-17 18:41:54 +00:00
gpt-engineer-app[bot] b81e1cf47d Code edited in Lovable Code Editor 2025-10-17 18:32:40 +00:00
gpt-engineer-app[bot] 9ab3530a80 Refactor: Change opinion agent icon 2025-10-17 18:27:52 +00:00
gpt-engineer-app[bot] c37c854b07 feat: Add pagination to opinion search 2025-10-17 17:22:30 +00:00
gpt-engineer-app[bot] b0a1cfdab5 Refactor Agent View 2025-10-17 16:31:41 +00:00
149 changed files with 31144 additions and 1319 deletions
+18
View File
@@ -0,0 +1,18 @@
# API Configuration
# Base URL da API do HGTX
VITE_API_BASE_URL=https://prod-hgtx-intelligence-n8n.hgtx.com.br
VITE_BASE_URL_OPEN_CODEX_APIs=https://codex-api.hgtx.com.br/api/
VITE_BASE_URL_HGTX_CORE_API=https://core-api.hgtx.com.br/api/
VITE_BASE_URL_HGTX_CORE=https://core.hgtx.com.br/
# API Key para autenticação
# IMPORTANTE: Mantenha esta chave segura e não compartilhe publicamente
VITE_API_KEY=your-api-key-here
# User Configuration
# Configure com os dados do usuário/estabelecimento
VITE_USER_EMAIL=user@example.com
VITE_ESTABELECIMENTO_ID=1
# API do módulo de fechamento (Commander)
VITE_API_BASE_URL_COMMANDER=https://hgtx-opencommander-api.goutron.com.br/api
+5
View File
@@ -12,6 +12,11 @@ dist
dist-ssr dist-ssr
*.local *.local
# Environment variables
.env
.env.local
.env.production
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
BIN
View File
Binary file not shown.
+4
View File
@@ -10,11 +10,15 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}"
} }
} }
+385
View File
@@ -0,0 +1,385 @@
# Design System — HGTX Commander
> Referência técnica de tokens e padrões — light + dark.
> **Fonte da verdade (código):** `src/index.css` + `tailwind.config.ts`.
> Tema aplicado via `next-themes` (`class` no `<html>`, `storageKey: hgtx-commander-theme`).
## Cor de marca (azul HGTX)
Azul institucional usado em botões primários, links, anéis de foco e destaques do Commander.
| Papel | Light | Dark |
|-------|-------|------|
| **Primária** | `hsl(210 100% 33%)` · **#0054A8** | `hsl(210 90% 52%)` · **#1685F3** |
| Hover | `hsl(210 100% 28%)` · #00478F | `hsl(210 95% 46%)` · #0675E5 |
| Pressed | `hsl(210 100% 24%)` · #003D7A | `hsl(210 100% 40%)` · #0066CC |
| Fundo sutil | `hsl(210 55% 96%)` · #EFF5FB | `hsl(210 50% 14%)` · #122033 |
| Focus ring | `rgba(0, 85, 164, 0.28)` | `rgba(51, 136, 255, 0.35)` |
Referência histórica de marca: ~`#0055A4` / `#004080` — no código o token `--primary` (light) resolve para **#0054A8**.
**Tailwind / shadcn:** `bg-primary`, `text-primary`, `border-primary`, `ring-ring` (usa `--ring`, alinhado ao primário).
---
## Fonte
Inter — Google Fonts
Pesos: 400, 500, 600, 700
Import: `https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap`
Token: `--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif`
---
## Cores
Valores em **HSL sem vírgula** (formato shadcn: `hsl(var(--token))`). Hex entre parênteses para referência visual.
### Primária e estados
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--primary` | 210 100% 33% · #0054A8 | 210 90% 52% · #1685F3 | `bg-primary` `text-primary` | CTAs, enviar, links ativos |
| `--primary-foreground` | 0 0% 100% · #FFFFFF | 0 0% 100% · #FFFFFF | `text-primary-foreground` | Texto sobre primário |
| `--primary-hover` | 210 100% 28% · #00478F | 210 95% 46% · #0675E5 | `bg-primary-hover` | Hover botões primários |
| `--primary-pressed` | 210 100% 24% · #003D7A | 210 100% 40% · #0066CC | `bg-primary-pressed` | Active / pressed |
| `--primary-light` | 210 55% 96% · #EFF5FB | 210 50% 14% · #122033 | `bg-primary-light` | Fundo de destaque suave |
| `--ring` | 210 100% 33% | 210 90% 52% | `ring-ring` | Focus visível (inputs) |
| `--focus-ring` | rgba(0, 85, 164, 0.28) | rgba(51, 136, 255, 0.35) | sombra manual | Focus em componentes custom |
### Semânticas (feedback)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--success` | 134 61% 41% · #29A847 | 141 71% 48% · #23D160 | `bg-success` `text-success` | Online, confirmação |
| `--success-foreground` | 0 0% 100% | 216 48% 8% | `text-success-foreground` | Texto sobre success |
| `--destructive` | 0 84% 60% | 0 84% 60% | `bg-destructive` | Erro, excluir |
| `--destructive-foreground` | 0 0% 100% | 0 0% 100% | `text-destructive-foreground` | Texto sobre destructive |
| `--warning` | 38 92% 50% | 38 92% 50% | `bg-warning` `text-warning` | Alertas, badge “Pessoal” |
| `--warning-foreground` | 209 11% 15% | 216 48% 8% | `text-warning-foreground` | Texto sobre warning |
### Superfícies (page, card, sidebar)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--background` | 0 0% 100% · #FFFFFF | 216 48% 8% · #0B121E | `bg-background` | Fundo principal / chat |
| `--foreground` | 209 11% 15% · #22262A | 0 0% 100% · #FFFFFF | `text-foreground` | Texto principal |
| `--card` | 0 0% 100% · #FFFFFF | 216 42% 11% · #101A28 | `bg-card` | Cards, modais, composer |
| `--card-foreground` | 209 11% 15% | 0 0% 100% | `text-card-foreground` | Texto em cards |
| `--popover` | 0 0% 100% | 216 42% 11% | `bg-popover` | Dropdowns, selects |
| `--sidebar-background` | 210 17% 98% · #F8FAFB | 216 50% 9% · #0B1522 | `bg-sidebar` | Sidebar Commander / módulos |
| `--sidebar-foreground` | 209 11% 15% | 0 0% 100% | `text-sidebar-foreground` | Texto na sidebar |
| `--sidebar-border` | 220 13% 91% · #E5E7EB | 212 12% 21% · #2F363F | `border-sidebar-border` | Divisor da sidebar |
| `--sidebar-accent` | 210 70% 94% · #E3EEFC | 210 45% 16% · #16283D | `bg-sidebar-accent` | Item de nav ativo / hover |
| `--sidebar-accent-foreground` | 210 100% 33% | 210 85% 62% · #5BA3EB | `text-sidebar-accent-foreground` | Texto no item ativo |
| `--muted` | 210 17% 96% · #F3F6F8 | 216 38% 13% · #15202E | `bg-muted` | Fundos secundários |
| `--accent` | 210 70% 94% | 210 45% 16% | `bg-accent` | Hover suave, chips |
| `--accent-foreground` | 210 100% 33% | 210 85% 62% | `text-accent-foreground` | Texto em accent |
### Tokens extras (chat e UI)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Uso |
|-----------|-------------------|------------------|-----|
| `--bubble-bot` | 209 36% 96% | 216 36% 14% | Bolha do assistente |
| `--nav-active` | 210 70% 94% | 210 45% 16% | Nav ativo (alias visual do accent) |
| `--input-bg` | 0 0% 100% | 216 40% 10% | Fundo de campos (referência) |
| `--secondary` | 210 6% 60% | 216 38% 13% | Neutro secundário (evitar texto em badge dark) |
| `--secondary-foreground` | 209 11% 15% | 0 0% 100% | Texto sobre secondary |
### Bordas e inputs (shadcn)
No shadcn, `--input` é a **cor da borda** de Input / Select / Textarea, não o fundo do campo.
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--border` | 220 13% 91% · #E5E7EB | 216 18% 26% · #36404E | `border-border` | Divisores, cards |
| `--input` | 220 9% 83% · #D0D2D8 | 216 16% 30% · #404A59 | `border-input` | Borda de inputs |
| `--border-subtle` | 220 13% 95% | 216 22% 20% | custom | Bordas muito suaves |
| `--btn-secondary-border` | 220 9% 83% | 216 16% 30% | custom | Outline secundário |
### Texto secundário
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--muted-foreground` | 209 7% 46% · #6D767E | 212 9% 58% · #8B949E | `text-muted-foreground` | Subtítulos, hints |
| `--placeholder` | 220 9% 66% | 212 10% 31% | `text-placeholder` / `placeholder:` | Placeholders |
### Gradientes e glow (efeitos)
| Token | Light | Dark |
|-------|-------|------|
| `--gradient-cyber` | `135deg`, hsl(210 100% 33%) → hsl(210 90% 42%) | hsl(210 90% 52%) → hsl(210 100% 40%) |
| `--gradient-subtle` | #FFF → hsl(210 17% 98%) | hsl(216 42% 11%) → hsl(216 48% 8%) |
| `--glow-cyan` / `--glow-blue` | sombra azul suave 1215% opac. | sombra azul 2835% opac. |
Classes utilitárias: `.gradient-text`, `.cyber-glow`, `.glass-effect` em `src/index.css`.
---
## Tipografia
> Mesma escala em light e dark.
| Nível | Tamanho | Peso | Line Height | Classe Semântica |
|-------|---------|------|-------------|------------------|
| Display / Commander | 32px (`--fs-display`) | 700 | 1.25 (`--lh-tight`) | text-display |
| Heading 1 — Modal | 24px (`--fs-h1`) | 600 | 1.25 | text-h1 |
| Heading 2 — Seção | 18px (`--fs-h2`) | 600 | 1.5 (`--lh-body`) | text-h2 |
| Heading 3 | 15px (`--fs-h3`) | — | — | text-h3 |
| Body | 14px (`--fs-body`) | 400 | 1.5 | text-body |
| Small / Timestamp | 12px (`--fs-small`) | 400 | — | text-small |
| Caption | 11px (`--fs-caption`) | 500 | — | text-caption |
| Nav link | 13px | 500 (`--fw-medium`) | — | text-nav-link |
| Nav brand | 15px | 700 (`--fw-bold`) | — | text-nav-brand |
| Brand lockup (tagline) | 10px | 600 | — | text-brand-tag |
| Brand lockup (nome) | 16px | 700 | — | text-brand-name |
| Hero título (catálogo) | clamp(28px, 4vw, 42px) | 700 | 1.25 | text-hero |
| Section title | 28px | 700 | — | text-section-title |
Tokens de peso: `--fw-regular` 400, `--fw-medium` 500, `--fw-semibold` 600, `--fw-bold` 700.
Tokens de line-height: `--lh-tight` 1.25, `--lh-body` 1.5, `--lh-relaxed` 1.65.
---
## Espaçamento
Base unit: **4px**
| Token | Valor | Classe Semântica |
|-------|-------|------------------|
| --space-1 | 4px | p-1 / m-1 / gap-1 |
| --space-2 | 8px | p-2 / m-2 / gap-2 |
| --space-3 | 12px | p-3 / m-3 / gap-3 |
| --space-4 | 16px | p-4 / m-4 / gap-4 |
| --space-5 | 20px | p-5 / m-5 / gap-5 |
| --space-6 | 24px | p-6 / m-6 / gap-6 |
| --space-7 | 32px | p-7 / m-7 / gap-7 |
| --space-8 | 48px | p-8 / m-8 / gap-8 |
| --space-9 | 64px | p-9 / m-9 / gap-9 |
| --section-y | 64px | py-section |
| --section-x | 40px | px-section |
| --nav-h | 56px | h-nav |
| --container | 1200px | max-w-container |
---
## Border Radius
| Token | Valor | Classe Semântica | Uso |
|-------|-------|------------------|-----|
| --radius-sm | 6px | rounded-sm | Chat items, detalhes |
| --radius-md | 8px | rounded-md | Botões, inputs, nav items, badges |
| --radius-lg | 12px | rounded-lg | Cards, bolhas, demo wrap |
| --radius-xl | 16px | rounded-xl | Modal Personalidade |
| --radius-pill | 999px | rounded-pill | Chat bar (composer) |
| 50% | — | rounded-full | Botão send circular |
---
## Sombras
| Token CSS | Light | Dark | Classe Tailwind | Uso |
|-----------|-------|------|-----------------|-----|
| `--shadow-card` | `0 4px 12px rgba(0,0,0,0.06)` | `0 4px 16px rgba(0,0,0,0.35)` | `shadow-card` | Cards elevados |
| `--shadow-modal` | `0 16px 40px rgba(0,0,0,0.12)` | `0 24px 48px rgba(0,0,0,0.55)` | `shadow-modal` | Modais, dialogs |
Glow Online (badge): `box-shadow: 0 0 8px hsl(var(--success) / 0.5)`.
---
## Componentes
> Use referências a tokens — valores resolvem para light/dark conforme o tema ativo.
### Botão Primary (HGTX — Nova Sessão / Salvar Perfil)
```
bg-primary hover:bg-primary-hover active:bg-primary-pressed
text-white rounded-md px-[18px] py-[10px]
text-[13px] font-semibold inline-flex items-center gap-2
border-none cursor-pointer transition-[background] duration-200
disabled:opacity-45 disabled:cursor-not-allowed
```
### Botão Primary (catálogo / hero)
```
bg-primary hover:bg-primary-hover active:bg-primary-pressed active:translate-y-px
text-white rounded-md px-6 py-3 text-sm font-semibold
border-none cursor-pointer transition-all duration-200
```
### Botão Outline (Nova Pasta)
```
bg-transparent text-primary border border-btn-secondary
rounded-md px-[18px] py-[10px] text-[13px] font-medium
inline-flex items-center gap-2 cursor-pointer
hover:border-secondary transition-[border-color] duration-200
```
### Botão Secondary (catálogo)
```
bg-transparent text-primary border border-btn-secondary
rounded-md px-6 py-3 text-sm font-medium
hover:border-secondary hover:bg-black/[0.03] (light) / hover:bg-white/[0.04] (dark)
transition-all duration-200 cursor-pointer
```
### Botão Ghost (Cancelar)
```
bg-transparent text-secondary border-none rounded-md px-[14px] py-2
text-[13px] cursor-pointer
hover:text-primary hover:bg-black/[0.04] (light) / hover:bg-white/[0.05] (dark)
transition-all duration-200
```
### Botão Send (circular)
```
w-10 h-10 rounded-full bg-primary text-white border-none
flex items-center justify-center text-base cursor-pointer
```
### Nav Sticky (catálogo DS)
```
sticky top-0 z-[100] h-nav flex items-center justify-between px-section
bg-nav backdrop-blur-[12px] border-b border-divider
```
### Nav CTA
```
bg-primary hover:bg-primary-hover text-white rounded-md
px-[18px] py-2 text-[13px] font-semibold border-none cursor-pointer
```
### Nav Item (sidebar)
```
flex items-center gap-3 px-3 py-2 rounded-md text-[13px] text-secondary
```
### Nav Item Ativo
```
bg-nav-active text-primary
```
### Nav Chat Item (lista conversas)
```
flex justify-between items-center px-3 py-2 text-xs text-secondary rounded-sm
/* timestamp */ text-[11px] text-placeholder
```
### Input / Select / Textarea
```
font-sans text-sm px-[14px] py-[10px] rounded-md
border border-divider bg-input text-primary outline-none w-full
placeholder:text-placeholder
transition-[border-color,box-shadow] duration-200
focus:border-primary focus:shadow-[0_0_0_3px_var(--color-focus-ring)]
disabled:opacity-50 disabled:cursor-not-allowed
```
### Textarea (modal)
```
[classes input] min-h-[120px] resize-y
```
### Input Search (com ícone)
```
relative /* wrap */
[input] pl-9
/* ícone */ absolute left-3 top-1/2 -translate-y-1/2 text-placeholder text-sm
```
### Chat Composer Bar
```
flex items-center gap-3 py-2 pr-2 pl-5
border border-divider rounded-pill bg-input max-w-[560px]
/* input interno */ flex-1 border-none bg-transparent text-sm text-primary outline-none placeholder:text-placeholder
/* + btn-send */
```
### Badge Online
```
inline-flex items-center gap-2 text-small font-medium text-secondary
/* dot */ w-2 h-2 rounded-full bg-success shadow-[0_0_8px_rgba(35,209,96,0.5)]
```
### Badge Self-Hosted
```
inline-flex items-center gap-2 text-caption text-placeholder
px-3 py-2 border border-divider rounded-md
```
### Card Modal (Personalidade)
```
max-w-[480px] p-6 bg-surface-card border border-divider rounded-xl shadow-modal
```
### Card Modal Header
```
flex justify-between items-start mb-4
/* título */ text-h2 font-semibold
```
### Card Modal Close
```
w-8 h-8 border-none rounded-md bg-transparent text-secondary text-lg cursor-pointer
hover:text-primary hover:bg-black/[0.05] (light) / hover:bg-white/[0.06] (dark)
```
### Card Modal Actions
```
flex justify-end gap-3 mt-5
/* Cancelar: btn-hgtx-ghost | Salvar: btn-hgtx-primary */
```
### Bolha Bot (boas-vindas)
```
max-w-[420px] px-5 py-4 bg-bubble-bot rounded-lg
text-sm text-secondary leading-relaxed
```
### Layout Demo (3 colunas)
```
grid grid-cols-[200px_280px_1fr] border border-divider rounded-lg overflow-hidden min-h-[320px]
/* sidebars */ bg-sidebar p-4 border-r border-divider
/* main */ bg-page p-5 flex flex-col items-center justify-center
```
### Brand Lockup (sidebar)
```
text-[10px] font-bold uppercase tracking-[0.12em] text-secondary mb-4
/* nome */ block text-sm text-primary tracking-[0.06em]
```
### Folder Label (sidebar)
```
text-xs font-medium text-secondary py-2 mt-3
```
---
## Tema — como aplicar
1. **Tokens light** em `:root` e **tokens dark** em `.dark` — ver `src/index.css`.
2. **App:** `ThemeProvider` (`next-themes`) com `attribute="class"`, `storageKey="hgtx-commander-theme"`, temas `light` | `dark`.
3. **Tailwind:** cores semânticas em `tailwind.config.ts``hsl(var(--primary))`, etc.
Tokens **compartilhados** (não mudam com o tema): `--fs-*`, `--space-*`, `--radius-*`, `--fw-*`, `--lh-*`, `--section-*`, `--nav-h`, `--container`.
Texto sobre botões primários: sempre `--primary-foreground` (`#FFFFFF`).
### Mapeamento rápido (legado → atual)
| Doc / HTML antigo | Token atual |
|-------------------|-------------|
| `#0061ff` / `#1a73e8` | `--primary`#0054A8 (light) / #1685F3 (dark) |
| `bg-page` | `bg-background` |
| `bg-sidebar` | `bg-sidebar` (`--sidebar-background`) |
| `text-secondary` (corpo) | `text-muted-foreground` |
| `border-divider` | `border-border` |
---
*Atualizado para refletir `src/index.css` (HGTX Commander redesign).*
*Para uso como referência no Cursor e agentes de código.*
+9 -9
View File
@@ -1,20 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="pt-br">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HGTX Codex - AI Interface System</title> <title>HGTX Commander</title>
<meta name="description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" /> <meta name="description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
<meta name="author" content="HGTX Codex" /> <meta name="author" content="HGTX Commander" />
<meta property="og:title" content="HGTX Codex - AI Interface System" /> <meta property="og:title" content="HGTX Commander" />
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" /> <meta property="og:description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta property="og:image" content="/favicon.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" /> <meta name="twitter:site" content="@hgtx" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta name="twitter:image" content="/favicon.png" />
</head> </head>
<body> <body>
+1678 -9
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -32,7 +32,7 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-toast": "^1.2.14",
@@ -40,18 +40,24 @@
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@types/canvas-confetti": "^1.9.0",
"axios": "^1.12.2",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"motion": "^12.38.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.61.1", "react-hook-form": "^7.61.1",
"react-markdown": "^9.1.0",
"react-resizable-panels": "^2.1.9", "react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+30 -5
View File
@@ -2,23 +2,40 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect";
import React from "react";
import CommanderApp from "./modules/commander/App";
import IntelligenceIAApp from "./modules/intelligence-ia/App";
import IntelligenceScoreApp from "./modules/intelligence-score/App";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <ThemeProvider
attribute="class"
defaultTheme="light"
themes={["light", "dark"]}
enableSystem={false}
storageKey="hgtx-commander-theme"
enableColorScheme
>
<TooltipProvider> <TooltipProvider>
<Toaster /> <Toaster />
<Sonner /> <Sonner />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Index />} /> <Route path="/redir/:gsikey" element={<Redirect />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="/commander/*" element={<CommanderApp />} />
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
<Route path="/intelligence-score/*" element={<IntelligenceScoreApp />} />
<Route path="/" element={<Navigate to="/commander" replace />} />
<Route path="/404" element={<NotFound />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -28,3 +45,11 @@ const App = () => (
); );
export default App; export default App;
const HGTXLogin = () => {
React.useEffect(() => {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
});
return <></>;
};
+286
View File
@@ -0,0 +1,286 @@
import { jwtDecode } from 'jwt-decode';
import { RedirectService } from './pages/RedirectService';
export class GlobalFunctions {
public static getTranferAreaObj(): TransferAreaResponse {
let objRetorno: TransferAreaResponse = {
aplicativoID: 0,
aplicativoNome: "",
areaTransferenciaID: 0,
dataRegistro: "",
estabelecimentoID: 0,
usuarioID: 0,
usuarioEmail: "",
usuarioNome: "",
aplicativoShareCode: "",
nivelAcesso: 0
};
try {
const objTransferStr = sessionStorage.getItem("TRANSFER_AREA");
if (
objTransferStr != null &&
objTransferStr != undefined &&
objTransferStr.trim() != ""
) {
const objTransfer = <TransferAreaResponse>JSON.parse(objTransferStr);
objRetorno = objTransfer;
}
} catch (error) {}
return objRetorno;
}
public static getRedirectComponent(): string {
let objRetorno = "";
try {
const objTransfer = GlobalFunctions.getTranferAreaObj();
if (objTransfer != null) {
// let objRedir = objTransfer.find(x => x.name == 'REDIRECT');
// objRetorno = objRedir?.value ?? '';
objRetorno = objTransfer.urlDestino ?? "";
if (objRetorno.startsWith("/")) objRetorno = objRetorno.substring(1);
} else {
objRetorno = "";
}
} catch (error) {
//console.log(error);
objRetorno = "";
}
return objRetorno;
}
public static getTransferProperty(propertyName: TransferAreaProperties): any {
let objRetorno: any = "";
try {
const objTransfer = this.getTranferAreaObj();
if (objTransfer != null) {
//let obj: any = objTransfer[propertyName];
const element = objTransfer[propertyName];
objRetorno = element;
} else {
objRetorno = null;
}
} catch (error) {
objRetorno = null;
}
return objRetorno;
}
public static isUsuarioLogado(): boolean {
let jsonUsuario = sessionStorage.getItem('usuarioLogado');
if (jsonUsuario) {
let tokenDecode: any = GlobalFunctions.getUsuarioLogado();
let d: Date = new Date();
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
if (dateAtual > tokenDecode.exp) return false;
return true;
}
return false;
}
public static tokenExpired(tokenDecode: any): boolean {
let d: Date = new Date();
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
if (dateAtual > tokenDecode.exp) return true;
return false;
}
public static handleNavigateBack(): void {
try {
// Verifica se existe histórico de navegação
if (window.history && window.history.length > 1) {
window.history.back();
return;
}
// Tenta fechar a janela
if (!window.closed) {
try {
const windowRef = window.open('', '_self');
if (windowRef) {
windowRef.close();
// Pequeno timeout para verificar se a janela foi fechada
setTimeout(() => {
// Se ainda estiver aberta, redireciona
if (!windowRef.closed) {
window.location.href = "https://core.hgtx.com.br/aplicativos/";
}
}, 100);
return;
}
} catch (closeError) {
console.warn("Não foi possível fechar a janela:", closeError);
}
}
// Se não conseguiu fechar, redireciona
window.location.href = "https://core.hgtx.com.br/aplicativos/";
} catch (error) {
// Em caso de qualquer erro, garante o redirecionamento
console.error("Erro ao tentar navegar:", error);
window.location.href = "https://core.hgtx.com.br/aplicativos/";
}
}
public static getUsuarioLogado(): UsuarioLogadoData {
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
//return jwt_decode(objJwt.token);
return jwtDecode(objJwt.token);
} catch (error) {}
return {
EID: "0",
email: "",
exp: 0,
iat: 0,
jti: "",
name: "",
nbf: 0,
UID: "0",
};
}
public static getUsuarioLogadoToken(): string {
let retorno = "";
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
retorno = objJwt.token;
} catch (error) {}
return retorno;
}
public static getUsuarioLogadoRefreshToken(): string {
let retorno = "";
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
retorno = objJwt.refreshToken;
} catch (error) {}
return retorno;
}
public static getPerfilUsuario(): PerfilUsuario | null {
let retorno: PerfilUsuario | null = null;
const jsonUsuario = sessionStorage.getItem("perfilUsuario") ?? "";
try {
const objJwt = <PerfilUsuario>JSON.parse(jsonUsuario);
retorno = objJwt;
} catch (error) {
retorno = null;
}
return retorno;
}
public static getHighUserProfile(): number {
let retorno = 6;
const objPerfil = GlobalFunctions.getPerfilUsuario();
if (objPerfil) {
const tiposPerfil: number[] = objPerfil.results.map((v) => v.tipoPerfil);
retorno = Math.min(...tiposPerfil);
}
return retorno;
}
public static async getToken(): Promise<string> {
let token: string = GlobalFunctions.getUsuarioLogadoToken();
const tokenDecode: any = GlobalFunctions.getUsuarioLogado();
let currentDate = new Date();
// JWT exp is in seconds
if ((tokenDecode.exp * 1000) < currentDate.getTime()) {
//console.log("Token expired.");
let gsikey: number = parseInt(sessionStorage.getItem('gsikey') ?? '0');
RedirectService.loadTransferArea(gsikey).then((response) => {
if(response.data.errorCode == 0){
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
sessionStorage.setItem('usuarioLogado',JSON.stringify({
token: response.data.jwt.token,
refreshToken: response.data.jwt.refreshToken
}));
token = response.data.jwt.token;
}
// else if(error !== 1401){
// navigate('/',{replace: true});
// }
});
}
// else {
// //console.log("Valid token");
// result = true;
// }
return token;
}
}
export interface UsuarioLogadoData {
UID: string;
EID: string;
name: string;
email: string;
jti: string;
nbf: number;
exp: number;
iat: number;
}
export interface TransferAreaResponse {
estabelecimentoID: number;
nivelAcesso: number;
aplicativoID: number;
usuarioID: number;
areaTransferenciaID: number;
dataRegistro: string;
urlBase?: string;
urlDestino?: string;
aplicativoNome: string;
usuarioNome: string;
usuarioEmail: string;
aplicativoShareCode: string;
}
export enum TransferAreaProperties {
EstabelecimentoCodigo = "estabelecimentoID",
UsuarioCodigo = "usuarioID",
UsuarioNome = "usuarioNome",
UsuarioEmail = "usuarioEmail",
APP_COD = "aplicativoID",
APP_TITULO = "aplicativoNome",
areaTransferenciaID = "areaTransferenciaID",
urlBase = "urlBase",
urlDestino = "urlDestino",
aplicativoShareCode = "aplicativoShareCode",
nivelAcesso = "nivelAcesso",
}
type PerfilUsuario = {
errorCode: number;
errorMessage: string;
results: PerfilUsuarioResult[];
};
export type PerfilUsuarioResult = {
tipoPerfil: number;
itens: PerfilUsuarioItem[];
};
type PerfilUsuarioItem = {
id: number;
descricao: string;
identificador: string;
};
+58 -24
View File
@@ -1,4 +1,4 @@
import { ChevronDown, UserCog } from "lucide-react"; import { ChevronDown, UserCog, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -15,7 +15,11 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useState } from "react"; import { useState, useEffect } from "react";
import { fetchModelsFromDatabase, ModelConfig } from "@/config/models";
// Texto padrão da personalidade (mesmo valor usado em ChatView)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface ChatHeaderProps { interface ChatHeaderProps {
showModelSelector?: boolean; showModelSelector?: boolean;
@@ -27,20 +31,32 @@ interface ChatHeaderProps {
export const ChatHeader = ({ export const ChatHeader = ({
showModelSelector = false, showModelSelector = false,
selectedModel = "ChatGPT 4.1", selectedModel = "GPT-4o",
onModelChange, onModelChange,
systemPrompt = "Você é um assistente útil e prestativo.", systemPrompt = DEFAULT_SYSTEM_PROMPT,
onSystemPromptChange onSystemPromptChange
}: ChatHeaderProps) => { }: ChatHeaderProps) => {
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false); const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt); const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
const [models, setModels] = useState<ModelConfig[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const models = [ // Carrega modelos da API ao montar o componente
"ChatGPT 4.1", useEffect(() => {
"ChatGPT 5", const loadModels = async () => {
"Claude Sonnet 4.5", setIsLoadingModels(true);
"Claude Opus 4.1", try {
]; const modelsFromAPI = await fetchModelsFromDatabase();
setModels(modelsFromAPI);
} catch (error) {
console.error('Erro ao carregar modelos:', error);
} finally {
setIsLoadingModels(false);
}
};
loadModels();
}, []);
const handleSavePersonality = () => { const handleSavePersonality = () => {
if (onSystemPromptChange) { if (onSystemPromptChange) {
@@ -50,29 +66,47 @@ export const ChatHeader = ({
}; };
return ( return (
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-6 py-4 flex items-center justify-between"> <header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 md:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" /> <div className="w-2 h-2 rounded-full bg-success animate-online-pulse" />
<span className="text-sm text-muted-foreground">Online</span> <span className="text-sm text-muted-foreground">Online</span>
</div> </div>
{showModelSelector && onModelChange && ( {showModelSelector && onModelChange && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 glass-effect"> <Button
<span className="font-medium">{selectedModel}</span> variant="outline"
<ChevronDown className="w-4 h-4" /> className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
disabled={isLoadingModels}
>
{isLoadingModels ? (
<>
<Loader2 className="w-3 h-3 md:w-4 md:h-4 animate-spin" />
<span className="font-medium">Carregando...</span>
</>
) : (
<>
<span className="font-medium truncate max-w-[80px] md:max-w-none">{selectedModel}</span>
<ChevronDown className="w-3 h-3 md:w-4 md:h-4" />
</>
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50"> <DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
{models.map((model) => ( {models.map((model) => (
<DropdownMenuItem <DropdownMenuItem
key={model} key={model.id}
className="cursor-pointer" className="cursor-pointer"
onClick={() => onModelChange(model)} onClick={() => onModelChange(model.name)}
> >
{model} <div className="flex flex-col">
<span className="font-medium">{model.name}</span>
{model.description && (
<span className="text-xs text-muted-foreground">{model.description}</span>
)}
</div>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -84,11 +118,11 @@ export const ChatHeader = ({
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="gap-2 glass-effect" className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
onClick={() => setTempSystemPrompt(systemPrompt)} onClick={() => setTempSystemPrompt(systemPrompt)}
> >
<UserCog className="w-4 h-4" /> <UserCog className="w-3 h-3 md:w-4 md:h-4" />
<span className="font-medium">Personalidade</span> <span className="hidden md:inline font-medium">Personalidade</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
@@ -120,8 +154,8 @@ export const ChatHeader = ({
)} )}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="hidden md:block text-sm text-muted-foreground">
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"} {showModelSelector ? "Modelo de linguagem avançado" : "HGTX Commander"}
</div> </div>
</header> </header>
); );
+35
View File
@@ -0,0 +1,35 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
interface FullScreenLoaderProps {
show: boolean;
text?: string;
}
export default function FullScreenLoader({ show, text = "Carregando..." }: FullScreenLoaderProps) {
const [visible, setVisible] = useState(show);
useEffect(() => {
if (show) setVisible(true);
else {
// adiciona um pequeno atraso para suavizar o fade-out
const timeout = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(timeout);
}
}, [show]);
if (!visible && !show) return null;
return (
<div
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200 ${
show ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-card shadow-lg border border-border transition-all duration-200">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-card-foreground text-sm font-medium">{text}</p>
</div>
</div>
);
}
+38 -19
View File
@@ -1,16 +1,17 @@
import { useState } from "react"; import { useState } from "react";
import { MessageSquare, Image, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Sparkles } from "lucide-react"; import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, FileText, Layers } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalFunctions } from "@/GlobalFunctions";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
activeTab: "chat" | "images" | "audio" | "bots" | "agent"; activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
onTabChange: (tab: "chat" | "images" | "audio" | "bots" | "agent") => void; onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas") => void;
} }
type TabType = "chat" | "images" | "audio" | "bots" | "agent"; type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => { export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -18,16 +19,19 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
const tabs = [ const tabs = [
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare }, { id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
{ id: "images" as TabType, label: "Imagens", icon: Image }, { id: "images" as TabType, label: "Imagens", icon: Image },
{ id: "audio" as TabType, label: "Áudio", icon: Mic }, { id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
{ id: "bots" as TabType, label: "Bots", icon: Bot }, { id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Sparkles }, //{ id: "bots" as TabType, label: "Bots", icon: Bot },
{ id: "prompts" as TabType, label: "Prompts", icon: FileText },
{ id: "parecerJuridico" as TabType, label: "Parecer Jurídico", icon: Bot },
{ id: "areas" as TabType, label: "Áreas", icon: Layers },
]; ];
return ( return (
<div className="flex h-screen w-full bg-background overflow-hidden"> <div className="flex h-screen w-full bg-background overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
{!isSidebarCollapsed ? ( {!isSidebarCollapsed ? (
<aside className="w-72 bg-sidebar border-r border-sidebar-border flex flex-col"> <aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
{/* Logo */} {/* Logo */}
<div className="p-6 border-b border-sidebar-border flex items-center justify-between"> <div className="p-6 border-b border-sidebar-border flex items-center justify-between">
<div> <div>
@@ -74,14 +78,14 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
<span className="text-sm font-medium text-sidebar-foreground">Tema</span> <span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle /> <ThemeToggle />
</div> </div>
<Button variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground"> <Button onClick={GlobalFunctions.handleNavigateBack} variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Voltar Voltar
</Button> </Button>
</div> </div>
</aside> </aside>
) : ( ) : (
<aside className="w-16 bg-sidebar border-r border-sidebar-border flex flex-col"> <aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
{/* Collapsed Header with Expand Button */} {/* Collapsed Header with Expand Button */}
<div className="p-3 border-b border-sidebar-border space-y-2"> <div className="p-3 border-b border-sidebar-border space-y-2">
<Button <Button
@@ -127,12 +131,7 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
{/* Theme & Settings Icons */} {/* Theme & Settings Icons */}
<div className="p-3 border-t border-sidebar-border space-y-2"> <div className="p-3 border-t border-sidebar-border space-y-2">
<ThemeToggle /> <ThemeToggle />
<Button <Button variant="ghost" size="icon" className="w-full text-sidebar-foreground" title="Voltar">
variant="ghost"
size="icon"
className="w-full text-sidebar-foreground"
title="Voltar"
>
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -140,9 +139,29 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden"> <main className="flex-1 flex flex-col overflow-hidden w-full">{children}</main>
{children}
</main> {/* Mobile Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all ${
isActive
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/70"
}`}
>
<Icon className={`w-5 h-5 ${isActive ? "text-primary" : ""}`} />
<span className="text-xs font-medium">{tab.label}</span>
</button>
);
})}
</nav>
</div> </div>
); );
}; };
-195
View File
@@ -1,195 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Download, Send, Sparkles } from "lucide-react";
import { LegalOpinion } from "./AgentView";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
interface AgentChatProps {
selectedOpinion: LegalOpinion | null;
isCreatingNew: boolean;
onOpinionCreated: (opinion: LegalOpinion) => void;
}
export const AgentChat = ({
selectedOpinion,
isCreatingNew,
onOpinionCreated,
}: AgentChatProps) => {
const [title, setTitle] = useState("");
const [instructions, setInstructions] = useState("");
const [category, setCategory] = useState("");
const [generatedContent, setGeneratedContent] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { toast } = useToast();
const handleGenerate = async () => {
if (!instructions.trim()) {
toast({
title: "Instruções necessárias",
description: "Por favor, forneça instruções para gerar o parecer.",
variant: "destructive",
});
return;
}
setIsGenerating(true);
// Simulação de chamada à IA - aqui você integraria com a API real
setTimeout(() => {
const mockContent = `PARECER JURÍDICO
TÍTULO: ${title || "Parecer Jurídico"}
${instructions}
ANÁLISE:
Este parecer foi gerado com base nas instruções fornecidas. A análise considera os seguintes aspectos legais:
1. Fundamentação Legal
2. Precedentes Judiciais
3. Doutrina Aplicável
4. Conclusão e Recomendações
CONCLUSÃO:
Com base na análise realizada, conclui-se que...
___________________________
Parecer gerado em ${new Date().toLocaleDateString('pt-BR')}`;
setGeneratedContent(mockContent);
setIsGenerating(false);
if (isCreatingNew) {
const newOpinion: LegalOpinion = {
id: Date.now().toString(),
title: title || "Novo Parecer",
content: mockContent,
createdAt: new Date(),
category: category || undefined,
};
onOpinionCreated(newOpinion);
}
toast({
title: "Parecer gerado com sucesso!",
description: "O parecer está pronto para download.",
});
}, 2000);
};
const handleDownloadDocx = () => {
// Aqui você implementaria a geração real do DOCX
// Por enquanto, vamos criar um arquivo de texto
const content = generatedContent || selectedOpinion?.content || "";
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || selectedOpinion?.title || 'parecer'}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
};
const displayContent = generatedContent || selectedOpinion?.content;
return (
<div className="flex flex-col h-full bg-background">
<div className="p-6 border-b border-border">
<h2 className="text-2xl font-bold text-foreground">
{selectedOpinion ? selectedOpinion.title : "Novo Parecer Jurídico"}
</h2>
{selectedOpinion && (
<p className="text-sm text-muted-foreground mt-1">
Criado em {new Date(selectedOpinion.createdAt).toLocaleDateString('pt-BR')}
</p>
)}
</div>
<div className="flex-1 overflow-hidden">
<div className="h-full p-6 space-y-4">
{(isCreatingNew || !selectedOpinion) && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Título do Parecer</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Categoria</label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Ex: Direito Civil, Trabalhista, etc."
/>
</div>
</>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{selectedOpinion ? "Instruções para novo modelo" : "Instruções para gerar o parecer"}
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
className="min-h-[120px]"
/>
</div>
<Button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full gap-2"
>
{isGenerating ? (
<>Gerando parecer...</>
) : (
<>
<Sparkles className="w-4 h-4" />
{selectedOpinion ? "Gerar Novo Modelo com IA" : "Gerar Parecer com IA"}
</>
)}
</Button>
{displayContent && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Parecer Gerado</label>
<Button
onClick={handleDownloadDocx}
variant="outline"
size="sm"
className="gap-2"
>
<Download className="w-4 h-4" />
Baixar DOCX
</Button>
</div>
<ScrollArea className="h-[300px] rounded-md border border-border p-4">
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
{displayContent}
</pre>
</ScrollArea>
</div>
)}
</div>
</div>
</div>
);
};
+138 -61
View File
@@ -1,72 +1,81 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Search, FileText } from "lucide-react"; import { Search, FileText } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { LegalOpinion } from "./AgentView"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
interface AgentSearchProps { interface AgentSearchProps {
onSelectOpinion: (opinion: LegalOpinion) => void; onSelectOpinion: (opinion: OpinionRecord) => void;
} }
// Simulação de base de pareceres
const mockOpinionsDatabase: LegalOpinion[] = [
{
id: "base-1",
title: "Análise Contratual - Prestação de Serviços Continuados",
content: "Parecer completo sobre prestação de serviços...",
createdAt: new Date("2024-01-15"),
category: "Direito Civil",
},
{
id: "base-2",
title: "Rescisão de Contrato de Trabalho - Justa Causa",
content: "Análise jurídica sobre rescisão contratual...",
createdAt: new Date("2024-02-20"),
category: "Direito Trabalhista",
},
{
id: "base-3",
title: "Responsabilidade Civil - Acidente de Trânsito",
content: "Parecer sobre responsabilidade civil em acidentes...",
createdAt: new Date("2024-03-10"),
category: "Direito Civil",
},
{
id: "base-4",
title: "Dissolução de Sociedade - Procedimentos e Requisitos",
content: "Análise completa sobre dissolução societária...",
createdAt: new Date("2024-01-25"),
category: "Direito Empresarial",
},
{
id: "base-5",
title: "Direitos do Consumidor - Vícios em Produtos",
content: "Parecer sobre direitos do consumidor e garantias...",
createdAt: new Date("2024-02-05"),
category: "Direito do Consumidor",
},
];
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => { export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]); const [searchResults, setSearchResults] = useState<OpinionRecord[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleSearch = () => { // Busca pareceres da API
const handleSearch = async () => {
setIsLoading(true);
try {
const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
setSearchResults(data);
} catch (error: any) {
console.error('Erro ao buscar pareceres:', error);
toast({
title: "Erro ao buscar pareceres",
description: error.message || "Não foi possível buscar os pareceres.",
variant: "destructive",
});
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// Busca automaticamente quando a página ou itemsPerPage mudam
useEffect(() => {
if (searchTerm.trim()) {
handleSearch();
}
}, [currentPage, itemsPerPage]);
// Debounce para busca automática
useEffect(() => {
if (!searchTerm.trim()) { if (!searchTerm.trim()) {
setSearchResults([]); setSearchResults([]);
return; return;
} }
const results = mockOpinionsDatabase.filter( const timer = setTimeout(() => {
(opinion) => if (currentPage === 1) {
opinion.title.toLowerCase().includes(searchTerm.toLowerCase()) || handleSearch();
opinion.content.toLowerCase().includes(searchTerm.toLowerCase()) || } else {
opinion.category?.toLowerCase().includes(searchTerm.toLowerCase()) setCurrentPage(1); // Volta para primeira página ao buscar
); }
}, 500);
setSearchResults(results); return () => clearTimeout(timer);
}; }, [searchTerm]);
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
@@ -76,21 +85,28 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
<Input <Input
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Digite título, frase ou categoria..." placeholder="Digite título ou categoria..."
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/> />
<Button onClick={handleSearch} className="gap-2"> <Button onClick={handleSearch} className="gap-2" disabled={isLoading}>
<Search className="w-4 h-4" /> <Search className="w-4 h-4" />
Buscar {isLoading ? 'Buscando...' : 'Buscar'}
</Button> </Button>
</div> </div>
{searchResults.length > 0 && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Base com {mockOpinionsDatabase.length} pareceres disponíveis {searchResults.length} {searchResults.length === 1 ? 'parecer encontrado' : 'pareceres encontrados'}
</p> </p>
)}
</div> </div>
<div className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-6"> <ScrollArea className="flex-1 p-6">
{searchResults.length > 0 ? ( {isLoading ? (
<div className="text-center text-muted-foreground py-12">
<p>Buscando pareceres...</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{searchResults.map((opinion) => ( {searchResults.map((opinion) => (
<div <div
@@ -101,13 +117,13 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-primary mt-1" /> <FileText className="w-5 h-5 text-primary mt-1" />
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-foreground">{opinion.title}</h3> <h3 className="font-semibold text-foreground">{opinion.titulo}</h3>
<div className="flex gap-4 mt-2 text-sm text-muted-foreground"> <div className="flex gap-4 mt-2 text-sm text-muted-foreground">
<span>{opinion.category}</span> <span>{opinion.categoria || '-'}</span>
<span>{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}</span> <span>{new Date(opinion.created_at).toLocaleDateString('pt-BR')}</span>
</div> </div>
<p className="text-sm text-muted-foreground mt-2 line-clamp-2"> <p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{opinion.content} {opinion.instrucoes}
</p> </p>
</div> </div>
</div> </div>
@@ -124,6 +140,67 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
</div> </div>
)} )}
</ScrollArea> </ScrollArea>
{searchResults.length > 0 && (
<div className="p-4 border-t border-border space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Itens por página:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
<span className="text-sm text-muted-foreground">
Mostrando {startIndex + 1}-{Math.min(startIndex + itemsPerPage, searchResults.length)} de {searchResults.length}
</span>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</div> </div>
); );
}; };
-121
View File
@@ -1,121 +0,0 @@
import { Plus, Search, Trash2, Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LegalOpinion } from "./AgentView";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface AgentSidebarProps {
opinions: LegalOpinion[];
onCreateNew: () => void;
onSelectOpinion: (opinion: LegalOpinion) => void;
onDeleteOpinion: (id: string) => void;
onSearch: () => void;
selectedOpinionId?: string;
}
export const AgentSidebar = ({
opinions,
onCreateNew,
onSelectOpinion,
onDeleteOpinion,
onSearch,
selectedOpinionId,
}: AgentSidebarProps) => {
const [searchTerm, setSearchTerm] = useState("");
const filteredOpinions = opinions.filter(op =>
op.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="w-80 border-r border-border bg-sidebar flex flex-col h-full">
<div className="p-4 border-b border-border space-y-3">
<h2 className="text-xl font-bold text-sidebar-foreground">Agente de Parecer</h2>
<Button onClick={onCreateNew} className="w-full gap-2">
<Plus className="w-4 h-4" />
Novo Parecer
</Button>
<Button onClick={onSearch} variant="outline" className="w-full gap-2">
<Search className="w-4 h-4" />
Pesquisar Base de Pareceres
</Button>
<Input
placeholder="Buscar pareceres..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Título</TableHead>
<TableHead className="w-[120px]">Data</TableHead>
<TableHead className="w-[100px] text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOpinions.map((opinion) => (
<TableRow
key={opinion.id}
className={selectedOpinionId === opinion.id ? "bg-sidebar-accent" : ""}
>
<TableCell className="font-medium">
<div>
<p className="truncate">{opinion.title}</p>
{opinion.category && (
<p className="text-xs text-muted-foreground mt-1">{opinion.category}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onSelectOpinion(opinion)}
title="Visualizar"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDeleteOpinion(opinion.id)}
title="Excluir"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filteredOpinions.length === 0 && (
<div className="text-center text-muted-foreground py-8">
<p className="text-sm">Nenhum parecer encontrado</p>
</div>
)}
</div>
</ScrollArea>
</div>
);
};
+509 -53
View File
@@ -1,74 +1,530 @@
import { useState } from "react"; import { useState, useEffect, useCallback } from "react";
import { AgentSidebar } from "./AgentSidebar"; import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { AgentChat } from "./AgentChat"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { OpinionDialog } from "./OpinionDialog";
import { AgentSearch } from "./AgentSearch"; import { AgentSearch } from "./AgentSearch";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
export interface LegalOpinion { type SortField = "titulo" | "created_at" | "categoria";
id: string; type SortOrder = "asc" | "desc";
title: string;
content: string;
createdAt: Date;
category?: string;
}
export const AgentView = () => { export const AgentView = () => {
const [opinions, setOpinions] = useState<LegalOpinion[]>([]); const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null); const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
const [isCreatingNew, setIsCreatingNew] = useState(false); const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [sortField, setSortField] = useState<SortField>("created_at");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedOpinion, setSelectedOpinion] = useState<OpinionRecord | null>(null);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [opinionToDelete, setOpinionToDelete] = useState<OpinionRecord | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const handleCreateNew = () => { // Carrega pareceres da API
setIsCreatingNew(true); const loadOpinions = useCallback(async () => {
setSelectedOpinion(null); setIsLoading(true);
setShowSearch(false); try {
}; const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
const handleSelectOpinion = (opinion: LegalOpinion) => { // Adiciona status aos pareceres da API
setSelectedOpinion(opinion); const opinionsWithStatus = data.map(opinion => ({
setIsCreatingNew(false); ...opinion,
setShowSearch(false); status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
}; }));
const handleSearch = () => { setOpinions(opinionsWithStatus);
setShowSearch(true);
setIsCreatingNew(false);
setSelectedOpinion(null);
};
const handleOpinionCreated = (newOpinion: LegalOpinion) => { // Remove registros temporários que agora estão na API
setOpinions([newOpinion, ...opinions]); setPendingOpinions(prev =>
setSelectedOpinion(newOpinion); prev.filter(pending =>
setIsCreatingNew(false); !opinionsWithStatus.some(opinion =>
}; opinion.titulo === pending.titulo &&
opinion.created_at.substring(0, 10) === pending.created_at.substring(0, 10)
)
)
);
} catch (error: any) {
console.error('Erro ao carregar pareceres:', error);
toast({
title: "Erro ao carregar pareceres",
description: error.message || "Não foi possível carregar a lista de pareceres.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [currentPage, itemsPerPage, searchTerm, toast]);
const handleDeleteOpinion = (id: string) => { // Polling automático para atualizar status dos pareceres
setOpinions(opinions.filter(op => op.id !== id)); useEffect(() => {
if (selectedOpinion?.id === id) { // Carrega imediatamente
setSelectedOpinion(null); loadOpinions();
// Configura polling a cada 10 segundos se houver pareceres pendentes
const interval = setInterval(() => {
if (pendingOpinions.length > 0) {
console.log('Polling: Atualizando lista de pareceres...');
loadOpinions();
}
}, 10000); // 10 segundos
return () => clearInterval(interval);
}, [currentPage, itemsPerPage, pendingOpinions.length, loadOpinions]);
// Debounce para busca
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadOpinions();
} else {
setCurrentPage(1); // Volta para a primeira página ao buscar
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, loadOpinions]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("asc");
} }
}; };
// Mescla pareceres da API com registros temporários
const allOpinions = [...pendingOpinions, ...opinions];
const sortedOpinions = [...allOpinions].sort((a, b) => {
const multiplier = sortOrder === "asc" ? 1 : -1;
if (sortField === "created_at") {
return multiplier * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
}
const aValue = a[sortField] || "";
const bValue = b[sortField] || "";
return multiplier * aValue.toString().localeCompare(bValue.toString());
});
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
// Callback quando um parecer está sendo criado (registro temporário)
const handleOpinionCreating = (tempOpinion: OpinionRecord) => {
setPendingOpinions(prev => [tempOpinion, ...prev]);
};
// Callback quando um parecer foi criado (recarrega da API)
const handleOpinionCreated = () => {
loadOpinions();
};
const handleViewOpinion = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setIsDialogOpen(true);
};
const handleNewOpinion = () => {
setSelectedOpinion(null);
setIsDialogOpen(true);
};
const handleSelectFromSearch = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setShowSearch(false);
setIsDialogOpen(true);
};
const handleDownloadVersion = async (opinion: OpinionRecord, version: 'v1' | 'v2') => {
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
const handleDeleteOpinion = async () => {
if (!opinionToDelete) return;
setIsDeleting(true);
try {
await agentService.deleteOpinion(opinionToDelete.id);
toast({
title: "Parecer excluído",
description: "O parecer foi excluído com sucesso.",
});
// Recarrega a lista de pareceres
await loadOpinions();
// Fecha o diálogo de confirmação
setOpinionToDelete(null);
} catch (error: any) {
toast({
title: "Erro ao excluir parecer",
description: error.message || "Não foi possível excluir o parecer.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
if (showSearch) {
return ( return (
<div className="flex h-full"> <div className="flex flex-col h-full">
<AgentSidebar <div className="p-6 border-b border-border">
opinions={opinions} <Button variant="outline" onClick={() => setShowSearch(false)}>
onCreateNew={handleCreateNew} Voltar para Pareceres
onSelectOpinion={handleSelectOpinion} </Button>
onDeleteOpinion={handleDeleteOpinion} </div>
onSearch={handleSearch} <AgentSearch onSelectOpinion={handleSelectFromSearch} />
selectedOpinionId={selectedOpinion?.id} </div>
);
}
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground">Gerador de Modelos de Parecer Jurídico</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Search className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Pesquisar Base</span>
<span className="sm:hidden">Base</span>
</Button>
<Button onClick={handleNewOpinion} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo Parecer</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar pareceres..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full md:max-w-sm text-sm"
/> />
<div className="flex-1"> <div className="flex items-center gap-2 justify-between md:justify-start">
{showSearch ? ( <span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<AgentSearch onSelectOpinion={handleSelectOpinion} /> <Select
value={itemsPerPage.toString()}
onValueChange={(value) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">
<Button
variant="ghost"
onClick={() => handleSort("titulo")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Título
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</TableHead>
<TableHead className="hidden md:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("categoria")}
className="flex items-center gap-1 font-semibold text-sm"
>
Categoria
<ArrowUpDown className="w-4 h-4" />
</Button>
</TableHead>
<TableHead className="hidden lg:table-cell text-center">
<span className="font-semibold text-xs md:text-sm">Status</span>
</TableHead>
<TableHead className="hidden sm:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("created_at")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Data
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
Carregando pareceres...
</TableCell>
</TableRow>
) : sortedOpinions.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
{searchTerm
? "Nenhum parecer encontrado"
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
</TableCell>
</TableRow>
) : ( ) : (
<AgentChat sortedOpinions.map((opinion) => (
selectedOpinion={selectedOpinion} <TableRow key={opinion.id}>
isCreatingNew={isCreatingNew} <TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
onOpinionCreated={handleOpinionCreated} <TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell>
/> <TableCell className="hidden lg:table-cell text-center">
{opinion.status === 'processando' && (
<Badge variant="secondary" className="gap-1 bg-yellow-100 text-yellow-800 hover:bg-yellow-100">
<Loader2 className="w-3 h-3 animate-spin" />
Gerando...
</Badge>
)}
{opinion.status === 'concluido' && (
<Badge variant="secondary" className="gap-1 bg-green-100 text-green-800 hover:bg-green-100">
<CheckCircle2 className="w-3 h-3" />
Concluído
</Badge>
)}
{opinion.status === 'erro' && (
<Badge variant="destructive" className="gap-1">
<XCircle className="w-3 h-3" />
Erro
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell text-xs md:text-sm">
{new Date(opinion.created_at).toLocaleDateString("pt-BR")}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewOpinion(opinion)}
title="Visualizar"
className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
>
<Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title="Baixar"
className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
>
<Download className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v1')}
disabled={!opinion.file_url}
>
<Download className="w-4 h-4 mr-2" />
Versão 1
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v2')}
disabled={!opinion.file_url_melhoria}
>
<Download className="w-4 h-4 mr-2" />
Versão Melhorada
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => setOpinionToDelete(opinion)}
title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive"
disabled={opinion.isLocalPending}
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{sortedOpinions.length > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {sortedOpinions.length} {sortedOpinions.length === 1 ? 'parecer' : 'pareceres'}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)} )}
</div> </div>
<OpinionDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
selectedOpinion={selectedOpinion}
onOpinionCreated={handleOpinionCreated}
onOpinionCreating={handleOpinionCreating}
/>
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir o parecer "{opinionToDelete?.titulo}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOpinion}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };
+297
View File
@@ -0,0 +1,297 @@
import { useState, useEffect } from "react";
import { Download, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { agentService, OpinionRecord } from "@/services/agent";
interface OpinionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedOpinion: OpinionRecord | null;
onOpinionCreated: () => void;
onOpinionCreating?: (tempOpinion: OpinionRecord) => void; // Callback para adicionar registro temporário
}
export const OpinionDialog = ({
open,
onOpenChange,
selectedOpinion,
onOpinionCreated,
onOpinionCreating,
}: OpinionDialogProps) => {
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [instructions, setInstructions] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [createdOpinion, setCreatedOpinion] = useState<OpinionRecord | null>(null);
const { toast } = useToast();
useEffect(() => {
if (selectedOpinion) {
setTitle(selectedOpinion.titulo);
setCategory(selectedOpinion.categoria || "");
setInstructions(selectedOpinion.instrucoes || "");
setCreatedOpinion(selectedOpinion);
} else {
setTitle("");
setCategory("");
setInstructions("");
setCreatedOpinion(null);
}
}, [selectedOpinion, open]);
const handleGenerate = async () => {
if (!instructions.trim()) {
toast({
title: "Instruções necessárias",
description: "Por favor, forneça instruções para gerar o parecer.",
variant: "destructive",
});
return;
}
if (!title.trim()) {
toast({
title: "Título necessário",
description: "Por favor, forneça um título para o parecer.",
variant: "destructive",
});
return;
}
// Criar registro temporário para adicionar na tabela imediatamente
const tempId = `temp-${Date.now()}`;
const tempOpinion: OpinionRecord = {
id: tempId,
estabelecimento_id: 0,
user_email: '',
titulo: title,
categoria: category,
instrucoes: instructions,
file_url: '',
file_url_melhoria: '',
created_at: new Date().toISOString(),
status: 'processando',
isLocalPending: true,
};
// Adiciona o registro temporário na tabela
if (onOpinionCreating) {
onOpinionCreating(tempOpinion);
}
// Criar toast de progresso que não fecha automaticamente
const loadingToast = toast({
title: "Gerando parecer jurídico...",
description: "O parecer está sendo processado. Isso pode levar alguns minutos.",
duration: Infinity, // Toast não fecha automaticamente
});
// Fecha o dialog imediatamente
setIsGenerating(false);
onOpenChange(false);
// Limpa os campos
setTitle("");
setCategory("");
setInstructions("");
setCreatedOpinion(null);
// Executar requisição em background
try {
const response = await agentService.createOpinion({
titulo: tempOpinion.titulo,
categoria: tempOpinion.categoria,
instrucoes: tempOpinion.instrucoes,
});
if (response.success) {
// Recarrega a lista para pegar o parecer real da API
onOpinionCreated();
// Atualizar toast para sucesso
loadingToast.update({
id: loadingToast.id,
title: "Parecer gerado com sucesso!",
description: "O parecer foi criado e está disponível para download.",
duration: 8000, // Toast fecha após 8 segundos
action: (
<ToastAction altText="Fechar notificação" onClick={() => loadingToast.dismiss()}>
OK
</ToastAction>
),
});
} else {
throw new Error('Falha ao criar parecer');
}
} catch (error: any) {
console.error("Erro ao gerar parecer:", error);
const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente.";
// Recarrega a lista para remover o registro temporário e tentar pegar dados reais
onOpinionCreated();
// Atualizar toast para erro
loadingToast.update({
id: loadingToast.id,
title: "Erro ao gerar parecer",
description: errorMessage,
variant: "destructive",
duration: 10000, // Toast de erro fecha após 10 segundos
});
}
};
const handleDownloadVersion = async (version: 'v1' | 'v2') => {
if (!createdOpinion && !selectedOpinion) {
toast({
title: "Nenhum parecer disponível",
description: "Por favor, gere um parecer primeiro.",
variant: "destructive",
});
return;
}
const opinion = createdOpinion || selectedOpinion;
if (!opinion) return;
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>
{selectedOpinion ? "Visualizar Parecer" : "Novo Parecer Jurídico"}
</DialogTitle>
<DialogDescription>
{selectedOpinion
? "Visualize os detalhes do parecer e faça o download das versões disponíveis"
: "Preencha os dados e instruções para gerar um novo parecer com IA"}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-120px)] pr-4">
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="title">Título do Parecer *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
disabled={!!selectedOpinion}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Categoria</Label>
<Input
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Ex: Direito Civil, Trabalhista, etc."
disabled={!!selectedOpinion}
/>
</div>
<div className="space-y-2">
<Label htmlFor="instructions">
Instruções para gerar o parecer *
</Label>
<Textarea
id="instructions"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
className="min-h-[150px]"
/>
</div>
{!selectedOpinion && (
<Button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full gap-2"
>
{isGenerating ? (
<>Gerando parecer...</>
) : (
<>
<Sparkles className="w-4 h-4" />
Gerar Parecer com IA
</>
)}
</Button>
)}
{selectedOpinion && (
<div className="space-y-3 pt-4 border-t">
<Label>Downloads Disponíveis</Label>
<div className="flex flex-col gap-2">
<Button
onClick={() => handleDownloadVersion('v1')}
variant="outline"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url}
>
<Download className="w-4 h-4" />
Baixar Versão 1
</Button>
<Button
onClick={() => handleDownloadVersion('v2')}
variant="outline"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url_melhoria}
>
<Download className="w-4 h-4" />
Baixar Versão Melhorada
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
+534
View File
@@ -0,0 +1,534 @@
import { useState, useMemo, useEffect } from "react";
import { Layers, Plus, Edit, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import { areasService, type AreaItem } from "@/services/areas";
import { toast } from "sonner";
export function AreasView() {
const { areas, setAreas, areaDescriptions, setAreaDescriptions, setPrompts } = usePrompts();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedAreaItem, setSelectedAreaItem] = useState<AreaItem | null>(null);
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [newName, setNewName] = useState("");
const [newDescription, setNewDescription] = useState("");
const [isCreatingArea, setIsCreatingArea] = useState(false);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isDeletingArea, setIsDeletingArea] = useState(false);
const selectedArea = selectedAreaItem?.nome ?? null;
const [filterNome, setFilterNome] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [areasList, setAreasList] = useState<AreaItem[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true);
const normalizedAreas = useMemo(
() => new Set(areas.map((area) => area.trim().toLocaleLowerCase())),
[areas]
);
const totalPages = Math.max(1, totalPaginas);
const isFiltering = filterNome.trim().length > 0;
const createName = newName.trim();
const isCreateNameDuplicated = createName.length > 0 && normalizedAreas.has(createName.toLocaleLowerCase());
const editNameNormalized = editName.trim().toLocaleLowerCase();
const selectedAreaNormalized = selectedAreaItem?.nome.trim().toLocaleLowerCase();
const isEditNameDuplicated =
editName.trim().length > 0 &&
editNameNormalized !== selectedAreaNormalized &&
normalizedAreas.has(editNameNormalized);
useEffect(() => {
let cancelled = false;
setLoadingList(true);
areasService.listar({
nome: filterNome.trim() || undefined,
page: currentPage,
per_page: itemsPerPage,
})
.then((res) => {
if (!cancelled) {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((prev) => [...new Set([...prev, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((prev) => ({
...prev,
...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])),
}));
}
})
.catch((e: unknown) => {
if (!cancelled) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar áreas.";
toast.error(msg);
setAreasList([]);
setTotalRegistros(0);
setTotalPaginas(1);
}
})
.finally(() => {
if (!cancelled) setLoadingList(false);
});
return () => { cancelled = true; };
}, [filterNome, currentPage, itemsPerPage]);
useEffect(() => {
setCurrentPage(1);
}, [filterNome]);
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
const handleCreate = async () => {
const name = newName.trim();
const description = newDescription.trim();
if (!name) return;
if (normalizedAreas.has(name.toLocaleLowerCase())) {
toast.error("Já existe uma área com esse nome.");
return;
}
setIsCreatingArea(true);
try {
await areasService.criar(name, description);
setAreas((prev) => [...prev, name].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((prev) => ({ ...prev, [name]: description }));
setNewName("");
setNewDescription("");
setIsCreateOpen(false);
toast.success("Área criada.");
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
.then((res) => {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
})
.catch(() => {});
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao criar área.";
toast.error(message);
} finally {
setIsCreatingArea(false);
}
};
const openEdit = (item: AreaItem) => {
setSelectedAreaItem(item);
setEditName(item.nome);
setEditDescription(item.descricao ?? areaDescriptions[item.nome] ?? "");
setIsEditOpen(true);
};
const refetchList = () => {
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
.then((res) => {
setAreasList(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
})
.catch(() => {});
};
const handleSaveEdit = async () => {
if (!selectedAreaItem || !editName.trim()) {
setIsEditOpen(false);
return;
}
const name = editName.trim();
const description = editDescription.trim();
if (
name.toLocaleLowerCase() !== selectedAreaItem.nome.trim().toLocaleLowerCase() &&
normalizedAreas.has(name.toLocaleLowerCase())
) {
toast.error("Já existe uma área com esse nome.");
return;
}
setIsSavingEdit(true);
try {
await areasService.editar(selectedAreaItem.id, name, description);
const oldName = selectedAreaItem.nome;
if (name !== oldName) {
setAreas((prev) => prev.map((a) => (a === oldName ? name : a)).sort((a, b) => a.localeCompare(b)));
setPrompts((prev) => prev.map((p) => (p.area === oldName ? { ...p, area: name } : p)));
}
setAreaDescriptions((prev) => {
const next = { ...prev };
if (oldName in next) delete next[oldName];
next[name] = description;
return next;
});
setSelectedAreaItem(null);
setIsEditOpen(false);
toast.success("Área atualizada.");
refetchList();
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao editar área.";
toast.error(message);
} finally {
setIsSavingEdit(false);
}
};
const openDelete = (item: AreaItem) => {
setSelectedAreaItem(item);
setIsDeleteOpen(true);
};
const handleDelete = async () => {
if (!selectedAreaItem || totalRegistros <= 1) return;
setIsDeletingArea(true);
try {
await areasService.deletar(selectedAreaItem.id);
const fallback = areasList.find((a) => a.id !== selectedAreaItem!.id)?.nome ?? areas.find((a) => a !== selectedAreaItem!.nome);
if (fallback) {
setPrompts((prev) =>
prev.map((p) => (p.area === selectedAreaItem.nome ? { ...p, area: fallback } : p))
);
}
setAreas((prev) => prev.filter((a) => a !== selectedAreaItem.nome));
setAreaDescriptions((prev) => {
const next = { ...prev };
delete next[selectedAreaItem.nome];
return next;
});
setSelectedAreaItem(null);
setIsDeleteOpen(false);
toast.success("Área excluída.");
refetchList();
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir área.";
toast.error(message);
} finally {
setIsDeletingArea(false);
}
};
const clearFilters = () => {
setFilterNome("");
setCurrentPage(1);
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Layers className="w-5 h-5 md:w-6 md:h-6" />
Áreas
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => setIsCreateOpen(true)} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Nova área</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
{(loadingList || totalRegistros > 0) && (
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar áreas..."
value={filterNome}
onChange={(e) => setFilterNome(e.target.value)}
className="w-full md:max-w-sm text-sm"
/>
{filterNome.trim() && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && totalRegistros === 0 && !isFiltering ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-5 h-5" />
Nenhuma área cadastrada
</CardTitle>
<CardDescription>
Crie a primeira área para usar nos prompts (ex.: Bate-papo, Imagens).
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setIsCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Nova área
</Button>
</CardContent>
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
Nenhum resultado para o filtro {filterNome.trim() ? `"${filterNome.trim()}"` : "atual"}.
</TableCell>
</TableRow>
) : (
areasList.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-xs md:text-sm">{item.nome}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(item)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-foreground"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openDelete(item)}
title="Excluir"
disabled={totalRegistros <= 1}
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loadingList && totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {totalRegistros} {totalRegistros === 1 ? "área" : "áreas"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Modal Novo */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova área</DialogTitle>
<DialogDescription>Informe o nome e, se quiser, a descrição da área. Ela poderá ser usada nos prompts.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="new-area">Nome</Label>
<Input
id="new-area"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Ex: Suporte"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
aria-invalid={isCreateNameDuplicated}
className={isCreateNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/>
{isCreateNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div>
<div>
<Label htmlFor="new-area-desc">Descrição</Label>
<Textarea
id="new-area-desc"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Ex: Área para prompts de atendimento"
rows={3}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={!newName.trim() || isCreateNameDuplicated || isCreatingArea}>
{isCreatingArea ? "Criando..." : "Criar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Editar */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar área</DialogTitle>
<DialogDescription>Altere o nome e a descrição da área. Os prompts vinculados serão atualizados.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="edit-area">Nome</Label>
<Input
id="edit-area"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()}
aria-invalid={isEditNameDuplicated}
className={isEditNameDuplicated ? "border-destructive focus-visible:ring-destructive" : undefined}
/>
{isEditNameDuplicated && (
<p className="mt-1 text-sm text-destructive">
existe uma área com esse nome. Escolha um nome diferente.
</p>
)}
</div>
<div>
<Label htmlFor="edit-area-desc">Descrição</Label>
<Textarea
id="edit-area-desc"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Ex: Área para prompts de atendimento"
rows={3}
className="resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}>
Cancelar
</Button>
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isEditNameDuplicated || isSavingEdit}>
{isSavingEdit ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal Excluir */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir área</AlertDialogTitle>
<AlertDialogDescription>
Deseja excluir a área "{selectedArea}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingArea} className="bg-white text-slate-900 hover:bg-slate-100 hover:text-slate-900">
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={totalRegistros <= 1 || isDeletingArea}
className={(totalRegistros <= 1 || isDeletingArea) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
>
{isDeletingArea ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+28 -21
View File
@@ -14,6 +14,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { audioGenerationService } from "@/services/audioGeneration";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm']; const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
@@ -186,19 +187,23 @@ export const AudioView = () => {
}, 2000); }, 2000);
}; };
const handleDownloadAudio = (audio: GeneratedAudio) => { const handleDownloadAudio = async (audio: GeneratedAudio) => {
// In real implementation, download the actual audio file const filename = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
const a = document.createElement('a'); try {
a.href = audio.audioUrl; await audioGenerationService.downloadAudioFile(audio.audioUrl, filename);
a.download = `audio_${audio.voiceLabel}_${new Date(audio.timestamp).getTime()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({ toast({
title: "Download iniciado", title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.", description: "O arquivo de áudio está sendo baixado.",
}); });
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
}; };
const handleDeleteAudio = (audioId: string) => { const handleDeleteAudio = (audioId: string) => {
@@ -270,22 +275,22 @@ export const AudioView = () => {
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader /> <ChatHeader />
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="transcription" className="space-y-6"> <Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-4 glass-effect"> <TabsList className="grid w-full grid-cols-2 md:grid-cols-4 glass-effect text-xs md:text-sm">
<TabsTrigger value="transcription"> <TabsTrigger value="transcription" className="text-xs md:text-sm">
Transcrição Transcrição
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="generation">Geração de Voz</TabsTrigger> <TabsTrigger value="generation" className="text-xs md:text-sm">Geração</TabsTrigger>
<TabsTrigger value="transcription-history"> <TabsTrigger value="transcription-history" className="text-xs md:text-sm">
Histórico Transcrições Hist. Trans.
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="audio-history"> <TabsTrigger value="audio-history" className="text-xs md:text-sm">
Histórico Áudios Hist. Áud.
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -482,10 +487,11 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
className="gap-1" className="gap-1"
onClick={() => handleDownloadAudio(generatedAudio)} onClick={() => void handleDownloadAudio(generatedAudio)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar MP3 Baixar MP3
@@ -658,9 +664,10 @@ export const AudioView = () => {
</div> </div>
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
<Button <Button
type="button"
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => handleDownloadAudio(item)} onClick={() => void handleDownloadAudio(item)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
</Button> </Button>
+475
View File
@@ -0,0 +1,475 @@
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Mic, Download, Trash2, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { audioGenerationService, VOICE_OPTIONS, VoiceType, AudioRecord } from "@/services/audioGeneration";
export const GenerationView = () => {
const [textToSpeech, setTextToSpeech] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
const [lastGeneratedAudio, setLastGeneratedAudio] = useState<AudioRecord | null>(null);
const [audioHistory, setAudioHistory] = useState<AudioRecord[]>([]);
const [isLoadingAudios, setIsLoadingAudios] = useState(false);
const [audioSearchQuery, setAudioSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega áudios do banco de dados
const loadAudios = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingAudios(true);
try {
const fetchedAudios = await audioGenerationService.getAudios(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedAudios)) {
setAudioHistory(fetchedAudios);
} else {
console.warn('Resposta da API não é um array:', fetchedAudios);
setAudioHistory([]);
}
} catch (error: any) {
console.error('Erro ao carregar áudios:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de áudios.",
variant: "destructive",
});
setAudioHistory([]);
} finally {
setIsLoadingAudios(false);
}
};
// Carrega áudios ao montar e quando a paginação mudar
useEffect(() => {
loadAudios();
}, [currentPage, perPage]);
const handleGenerateAudio = async () => {
// Valida o texto antes de enviar
const validation = audioGenerationService.validateText(textToSpeech);
if (!validation.valid) {
toast({
title: "Texto inválido",
description: validation.error,
variant: "destructive",
});
return;
}
setIsProcessing(true);
try {
// Chama o serviço de geração de áudio
const response = await audioGenerationService.generateAudio({
message: textToSpeech,
voice: selectedVoice,
});
// Verifica se a geração foi bem-sucedida
if (response.success) {
console.log('URL do áudio gerado:', response.audio_url);
// Cria objeto do áudio recém-gerado para exibição imediata
const newGeneratedAudio: AudioRecord = {
id: response.audio_generation_id,
user_email: '',
estabelecimento_id: 0,
input_text: response.message,
model: 'tts-1',
voice: selectedVoice,
audio_url: response.audio_url,
duration_seconds: null,
file_size: 0,
cost_usd: '0',
created_at: new Date().toISOString(),
};
// Salva o último áudio gerado para exibição
setLastGeneratedAudio(newGeneratedAudio);
toast({
title: "Áudio gerado com sucesso",
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
});
// Limpa o campo de texto após sucesso
setTextToSpeech("");
// Recarrega a lista de áudios (sem aguardar para não bloquear a UI)
loadAudios(1, perPage);
setCurrentPage(1);
} else {
throw new Error('Erro ao gerar áudio');
}
} catch (error: any) {
console.error('Erro na geração de áudio:', error);
toast({
title: "Erro na geração",
description: error.message || "Não foi possível gerar o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
};
const handleDownloadAudio = async (audio: AudioRecord) => {
const filename = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
try {
await audioGenerationService.downloadAudioFile(audio.audio_url, filename);
toast({
title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.",
});
} catch (error: unknown) {
const message =
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
toast({
title: "Erro ao baixar",
description: message,
variant: "destructive",
});
}
};
const handleDeleteAudio = async (audioId: string) => {
try {
const result = await audioGenerationService.deleteAudio(audioId);
if (result.success) {
toast({
title: "Áudio removido",
description: "O áudio foi removido com sucesso.",
});
// Se o áudio deletado for o último gerado, limpa o preview
if (lastGeneratedAudio && lastGeneratedAudio.id === audioId) {
setLastGeneratedAudio(null);
}
// Recarrega a lista de áudios
await loadAudios();
} else {
throw new Error(result.message || 'Erro ao deletar áudio');
}
} catch (error: any) {
console.error('Erro ao deletar áudio:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover o áudio.",
variant: "destructive",
});
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredAudios = Array.isArray(audioHistory)
? audioHistory.filter(item =>
(VOICE_OPTIONS[item.voice]?.label || item.voice).toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
item.input_text.toLowerCase().includes(audioSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="generation" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
<TabsTrigger value="generation" className="text-xs md:text-sm">
Geração
</TabsTrigger>
<TabsTrigger value="history" className="text-xs md:text-sm">
Histórico
</TabsTrigger>
</TabsList>
<TabsContent value="generation" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<h3 className="text-lg font-semibold">
Converter Texto em Voz
</h3>
<div className="space-y-2">
<label className="text-sm font-medium">
Digite o texto para converter
</label>
<Textarea
value={textToSpeech}
onChange={(e) => setTextToSpeech(e.target.value)}
placeholder="Digite o texto que deseja converter em fala..."
className="min-h-[120px] resize-none"
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de Voz</label>
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as VoiceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VOICE_OPTIONS).map(([key, voice]) => (
<SelectItem key={key} value={key}>
{voice.label} - {voice.gender}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{VOICE_OPTIONS[selectedVoice].label}</p>
<span className="text-xs text-muted-foreground">Modelo: OpenAI TTS-1</span>
</div>
<p className="text-xs text-muted-foreground">
<strong>Estilo:</strong> {VOICE_OPTIONS[selectedVoice].style}
</p>
<p className="text-xs text-muted-foreground">
{VOICE_OPTIONS[selectedVoice].description}
</p>
</div>
<Button
onClick={handleGenerateAudio}
disabled={!textToSpeech.trim() || isProcessing}
className="gap-2 cyber-glow w-full"
size="lg"
>
<Mic className="w-4 h-4" />
{isProcessing ? "Gerando..." : "Gerar Áudio"}
</Button>
</div>
{isProcessing && (
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">
Gerando áudio...
</p>
</div>
)}
</div>
{lastGeneratedAudio && (
<div className="glass-effect rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">Áudio Gerado</h4>
<p className="text-sm text-muted-foreground">
Voz: {VOICE_OPTIONS[lastGeneratedAudio.voice]?.label || lastGeneratedAudio.voice}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="gap-1"
onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
>
<Download className="w-3 h-3" />
Baixar
</Button>
<Button
size="sm"
variant="destructive"
className="gap-1"
onClick={() => handleDeleteAudio(lastGeneratedAudio.id)}
>
<Trash2 className="w-3 h-3" />
Excluir
</Button>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm mb-3">{lastGeneratedAudio.input_text}</p>
<audio
key={lastGeneratedAudio.id}
controls
className="w-full"
preload="metadata"
>
<source src={lastGeneratedAudio.audio_url} type="audio/mpeg" />
Seu navegador não suporta o elemento de áudio.
</audio>
</div>
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={audioSearchQuery}
onChange={(e) => setAudioSearchQuery(e.target.value)}
placeholder="Buscar por voz ou conteúdo..."
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingAudios ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando áudios...</p>
</div>
) : (
<>
{filteredAudios.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<Mic className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio gerado ainda'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredAudios.map((audio) => (
<div key={audio.id} className="glass-effect rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">
Voz: {VOICE_OPTIONS[audio.voice]?.label || audio.voice}
</h4>
</div>
<p className="text-xs text-muted-foreground">
{new Date(audio.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(audio.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void handleDownloadAudio(audio)}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAudio(audio.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{audio.input_text}
</p>
<audio
key={audio.id}
controls
className="w-full"
preload="metadata"
>
<source src={audio.audio_url} type="audio/mpeg" />
</audio>
</div>
))}
</div>
{/* Pagination */}
{!audioSearchQuery && audioHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={audioHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
);
};
+472
View File
@@ -0,0 +1,472 @@
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload, Download, Trash2, FileAudio, Copy, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { transcriptionService, TranscriptionRecord } from "@/services/transcription";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
export const TranscriptionView = () => {
const [isTranscribing, setIsTranscribing] = useState(false);
const [lastTranscriptionResult, setLastTranscriptionResult] = useState<TranscriptionRecord | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionRecord[]>([]);
const [isLoadingTranscriptions, setIsLoadingTranscriptions] = useState(false);
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega transcrições do banco de dados
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingTranscriptions(true);
try {
const fetchedTranscriptions = await transcriptionService.getTranscriptions(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedTranscriptions)) {
setTranscriptionHistory(fetchedTranscriptions);
} else {
console.warn('Resposta da API não é um array:', fetchedTranscriptions);
setTranscriptionHistory([]);
}
} catch (error: any) {
console.error('Erro ao carregar transcrições:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de transcrições.",
variant: "destructive",
});
setTranscriptionHistory([]);
} finally {
setIsLoadingTranscriptions(false);
}
};
// Carrega transcrições ao montar e quando a paginação mudar
useEffect(() => {
loadTranscriptions();
}, [currentPage, perPage]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Valida o arquivo usando o serviço
const validation = transcriptionService.validateAudioFile(file);
if (!validation.valid) {
toast({
title: "Arquivo inválido",
description: validation.error,
variant: "destructive",
});
return;
}
setSelectedFile(file);
handleTranscription(file);
};
const handleTranscription = async (file: File) => {
setIsTranscribing(true);
try {
// Chama o serviço de transcrição
const response = await transcriptionService.transcribeAudio({
audioFile: file,
});
// Verifica se a transcrição foi bem-sucedida
if (response.success) {
// Cria objeto da transcrição recém-gerada para exibição imediata
const newTranscription: TranscriptionRecord = {
id: response.transcription_id,
user_email: '',
estabelecimento_id: 0,
audio_file_name: file.name,
audio_duration_seconds: 0,
transcription_text: response.message,
model: 'whisper-1',
audio_url: response.audio_url,
cost_usd: '0',
created_at: new Date().toISOString(),
};
// Salva a última transcrição para exibição
setLastTranscriptionResult(newTranscription);
toast({
title: "Transcrição concluída",
description: "Seu áudio foi transcrito com sucesso!",
});
// Recarrega a lista de transcrições (sem aguardar para não bloquear a UI)
loadTranscriptions(1, perPage);
setCurrentPage(1);
} else {
throw new Error(response.message || 'Erro ao transcrever áudio');
}
} catch (error: any) {
console.error('Erro na transcrição:', error);
toast({
title: "Erro na transcrição",
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsTranscribing(false);
}
};
const handleDeleteTranscription = () => {
setLastTranscriptionResult(null);
setSelectedFile(null);
};
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
try {
const result = await transcriptionService.deleteTranscription(transcriptionId);
if (result.success) {
toast({
title: "Transcrição removida",
description: "A transcrição foi removida com sucesso.",
});
// Se a transcrição deletada for a última gerada, limpa o preview
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
setLastTranscriptionResult(null);
}
// Recarrega a lista de transcrições
await loadTranscriptions();
} else {
throw new Error(result.message || 'Erro ao deletar transcrição');
}
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a transcrição.",
variant: "destructive",
});
}
};
const handleCopyTranscription = (text: string) => {
navigator.clipboard.writeText(text);
toast({
title: "Texto copiado",
description: "A transcrição foi copiada para a área de transferência",
});
};
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transcricao_${transcription.audio_file_name}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Download iniciado",
description: "O arquivo de transcrição está sendo baixado.",
});
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredTranscriptions = Array.isArray(transcriptionHistory)
? transcriptionHistory.filter(item =>
item.audio_file_name.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
item.transcription_text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
<TabsTrigger value="transcription" className="text-xs md:text-sm">
Transcrição
</TabsTrigger>
<TabsTrigger value="history" className="text-xs md:text-sm">
Histórico
</TabsTrigger>
</TabsList>
<TabsContent value="transcription" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<h3 className="text-lg font-semibold">
Converter Áudio em Texto
</h3>
<p className="text-sm text-muted-foreground">
Envie um arquivo de áudio para transcrição (máx. 25MB)
</p>
<p className="text-xs text-muted-foreground">
Formatos: {SUPPORTED_FORMATS.join(', ')}
</p>
<input
type="file"
id="audio-upload"
className="hidden"
accept={SUPPORTED_FORMATS.map(f => `.${f}`).join(',')}
onChange={handleFileSelect}
/>
<label
htmlFor="audio-upload"
className="border-2 border-dashed border-border rounded-xl p-12 text-center space-y-4 hover:border-primary/50 transition-colors cursor-pointer block"
>
<div className="w-16 h-16 mx-auto rounded-full bg-primary/10 flex items-center justify-center">
{selectedFile ? (
<FileAudio className="w-8 h-8 text-primary" />
) : (
<Upload className="w-8 h-8 text-primary" />
)}
</div>
<div>
<p className="font-medium">
{selectedFile ? selectedFile.name : "Clique para selecionar ou arraste o arquivo"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{selectedFile
? `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
: "Máximo 25 MB"
}
</p>
</div>
{!selectedFile && (
<Button variant="outline" className="gap-2" type="button">
<Upload className="w-4 h-4" />
Selecionar Arquivo
</Button>
)}
</label>
{isTranscribing && (
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">
Transcrevendo áudio...
</p>
</div>
)}
</div>
{lastTranscriptionResult && (
<div className="glass-effect rounded-xl p-6 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
Transcrito {new Date(lastTranscriptionResult.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
>
<Copy className="w-3 h-3" />
Copiar
</Button>
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
>
<Download className="w-3 h-3" />
Baixar
</Button>
<Button
size="sm"
variant="destructive"
className="gap-1"
onClick={handleDeleteTranscription}
>
<Trash2 className="w-3 h-3" />
Excluir
</Button>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{lastTranscriptionResult.transcription_text}
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={transcriptionSearchQuery}
onChange={(e) => setTranscriptionSearchQuery(e.target.value)}
placeholder="Buscar por nome ou conteúdo..."
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingTranscriptions ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando transcrições...</p>
</div>
) : (
<>
{filteredTranscriptions.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredTranscriptions.map((item) => (
<div key={item.id} className="glass-effect rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
{new Date(item.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(item.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleCopyTranscription(item.transcription_text)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadTranscription(item)}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{item.transcription_text}
</p>
</div>
))}
</div>
{/* Pagination */}
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={transcriptionHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { authMeService } from "@/services/fechamento/authMe";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
type AuthGateProps = {
children: ReactNode;
};
const CORE_URL_FALLBACK = "https://core.hgtx.com.br";
export function AuthGate({ children }: AuthGateProps) {
const {
loading,
hasAccess,
reason,
errorMessage,
setAuthState,
} = useAuthAccess();
useEffect(() => {
let mounted = true;
const bootstrapAccess = async () => {
const isLogado = GlobalFunctions.isUsuarioLogado();
if (!isLogado) {
setAuthState({ loading: false, isAuthenticated: false, hasAccess: false });
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || CORE_URL_FALLBACK);
return;
}
let userEmail = GlobalFunctions.getUsuarioLogado().email;
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (typeof transferEmail === "string" && transferEmail.trim()) {
userEmail = transferEmail.trim();
}
}
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise((resolve) => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao obter token para validação de acesso:", error);
}
}
if (!userEmail) {
setAuthState({
loading: false,
isAuthenticated: false,
hasAccess: false,
reason: "erro",
errorMessage: "Não foi possível identificar o e-mail do usuário logado.",
});
return;
}
try {
const me = await authMeService.getMe(userEmail);
if (!mounted) {
return;
}
if (!me) {
const estabelecimentoCodigo = String(
GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
).trim();
if (estabelecimentoCodigo) {
try {
const bootstrap = await authMeService.getBootstrapStatus(estabelecimentoCodigo);
if (bootstrap.requiresBootstrap) {
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "bootstrap",
papel: null,
me: null,
errorMessage: estabelecimentoCodigo,
});
return;
}
} catch {
// Mantém fluxo padrão de não cadastrado caso não consiga consultar bootstrap.
}
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "nao_cadastrado",
papel: null,
me: null,
errorMessage: null,
});
return;
}
if (!me.estaAtivo) {
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "inativo",
papel: me.papel,
me,
errorMessage: null,
});
return;
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: true,
papel: me.papel,
me,
reason: null,
errorMessage: null,
});
} catch (error) {
if (!mounted) {
return;
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "erro",
errorMessage: error instanceof Error ? error.message : "Erro ao validar acesso.",
});
}
};
bootstrapAccess();
return () => {
mounted = false;
};
}, [setAuthState]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
if (!hasAccess) {
return <NoAccessScreen reason={reason} errorMessage={errorMessage} />;
}
return <>{children}</>;
}
+158
View File
@@ -0,0 +1,158 @@
import { useMemo, useState } from "react";
import { Loader2, ShieldX } from "lucide-react";
import { BorderBeam } from "@/components/ui/border-beam";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { Label } from "@/components/ui/label";
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
import { authMeService } from "@/services/fechamento/authMe";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
type NoAccessScreenProps = {
reason: AccessBlockReason | null;
errorMessage?: string | null;
};
function getMessage(reason: AccessBlockReason | null): string {
if (reason === "inativo") {
return "Seu usuário está inativo neste módulo. Solicite a reativação ao administrador.";
}
if (reason === "erro") {
return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
}
return "Você não tem acesso a este módulo no momento. Solicite acesso ao administrador.";
}
export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
const [unidadeNome, setUnidadeNome] = useState("");
const [adminNome, setAdminNome] = useState("");
const [adminEmail, setAdminEmail] = useState("");
const [saving, setSaving] = useState(false);
/** Código do estabelecimento: prioriza área de transferência (Codex), depois o valor repassado pelo AuthGate. */
const estabelecimentoId = useMemo(() => {
const fromTransfer = String(
GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
).trim();
const fromGate = (errorMessage ?? "").trim();
return fromTransfer || fromGate;
}, [errorMessage]);
const handleBootstrap = async () => {
if (!estabelecimentoId) {
return;
}
if (!unidadeNome.trim() || !adminNome.trim() || !adminEmail.trim()) {
alert("Preencha nome da unidade, nome do admin e e-mail.");
return;
}
try {
setSaving(true);
await authMeService.bootstrapInitialize({
estabelecimentoId,
unidadeNome: unidadeNome.trim(),
adminNome: adminNome.trim(),
adminEmail: adminEmail.trim(),
});
window.location.reload();
} catch (error) {
alert(error instanceof Error ? error.message : "Erro ao inicializar ambiente.");
} finally {
setSaving(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="relative w-full max-w-xl overflow-hidden">
<CardHeader className="text-center">
{reason === "bootstrap" ? (
<>
<CardTitle>Bem-vindo! Configuração inicial</CardTitle>
<CardDescription className="text-base leading-relaxed text-muted-foreground">
Este é o <strong className="font-medium text-foreground">primeiro acesso</strong> ao Fechamento HGTX
para este estabelecimento. Em poucos passos você cadastra a unidade e o primeiro usuário administrador
para começar a usar o sistema.
</CardDescription>
</>
) : (
<>
<div className="mb-3 flex justify-center">
<ShieldX className="h-10 w-10 text-destructive" />
</div>
<CardTitle>Você não tem acesso a este módulo</CardTitle>
</>
)}
</CardHeader>
<CardContent className="space-y-3 text-center">
{reason !== "bootstrap" ? <p className="text-muted-foreground">{getMessage(reason)}</p> : null}
{reason === "bootstrap" ? (
<div className="space-y-4 rounded-md border p-4 text-left">
<div className="space-y-1">
<Label>Código do estabelecimento</Label>
<Input value={estabelecimentoId} disabled className="font-mono bg-muted" />
<p className="text-xs text-muted-foreground">Preenchimento automático.</p>
{!estabelecimentoId ? (
<p className="text-xs text-amber-700 dark:text-amber-200">
Não foi possível obter o código do estabelecimento. Abra o Intelligence Score a partir do Commander com o
estabelecimento carregado no transfer.
</p>
) : null}
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
<Input
id="bootstrap-unidade"
value={unidadeNome}
onChange={(e) => setUnidadeNome(e.target.value)}
placeholder="Ex.: Unidade Matriz"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-nome">Nome do primeiro admin</Label>
<Input
id="bootstrap-admin-nome"
value={adminNome}
onChange={(e) => setAdminNome(e.target.value)}
placeholder="Ex.: João Silva"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-email">E-mail do primeiro admin</Label>
<Input
id="bootstrap-admin-email"
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@empresa.com"
/>
</div>
<div className="flex justify-end">
<InteractiveHoverButton
type="button"
onClick={handleBootstrap}
disabled={saving || !estabelecimentoId}
className="disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
Inicializando...
</span>
) : (
"Iniciar ambiente"
)}
</InteractiveHoverButton>
</div>
</div>
) : null}
{reason !== "bootstrap" && errorMessage ? (
<p className="text-sm text-muted-foreground">{errorMessage}</p>
) : null}
</CardContent>
<BorderBeam duration={8} size={100} />
</Card>
</div>
);
}
+24 -21
View File
@@ -95,19 +95,19 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
}; };
return ( return (
<div className="flex-1 flex flex-col h-full overflow-hidden"> <div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0">
<div className="border-b border-border p-6"> <div className="border-b border-border p-3 md:p-6">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
<div> <div>
<h2 className="text-2xl font-bold flex items-center gap-2"> <h2 className="text-xl md:text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" /> <Bot className="w-5 h-5 md:w-6 md:h-6" />
Gerenciador de Bots Gerenciador de Bots
</h2> </h2>
<p className="text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p> <p className="text-sm md:text-base text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
</div> </div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}> <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button className="w-full md:w-auto text-sm">
<Bot className="w-4 h-4 mr-2" /> <Bot className="w-4 h-4 mr-2" />
Criar Bot Criar Bot
</Button> </Button>
@@ -167,7 +167,7 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto p-6"> <div className="flex-1 overflow-auto p-3 md:p-6">
{bots.length === 0 ? ( {bots.length === 0 ? (
<Card className="max-w-2xl mx-auto mt-12"> <Card className="max-w-2xl mx-auto mt-12">
<CardHeader> <CardHeader>
@@ -190,45 +190,48 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Nome</TableHead> <TableHead className="text-xs md:text-sm">Nome</TableHead>
<TableHead>Modelo</TableHead> <TableHead className="hidden md:table-cell text-sm">Modelo</TableHead>
<TableHead className="text-right">Ações</TableHead> <TableHead className="text-right text-xs md:text-sm">Ações</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{currentBots.map((bot) => ( {currentBots.map((bot) => (
<TableRow key={bot.id}> <TableRow key={bot.id}>
<TableCell className="font-medium">{bot.name}</TableCell> <TableCell className="font-medium text-xs md:text-sm">{bot.name}</TableCell>
<TableCell>{bot.model}</TableCell> <TableCell className="hidden md:table-cell text-sm">{bot.model}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex gap-2 justify-end"> <div className="flex gap-1 justify-end flex-wrap">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onStartChat(bot)} onClick={() => onStartChat(bot)}
className="text-xs md:text-sm"
> >
<MessageSquare className="w-4 h-4 mr-2" /> <MessageSquare className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
Conversar <span className="hidden md:inline">Conversar</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => openEditDialog(bot)} onClick={() => openEditDialog(bot)}
className="text-xs md:text-sm"
> >
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
Editar <span className="hidden md:inline">Editar</span>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => openDeleteDialog(bot)} onClick={() => openDeleteDialog(bot)}
className="text-xs md:text-sm"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
Excluir <span className="hidden md:inline">Excluir</span>
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
+126 -48
View File
@@ -3,22 +3,44 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { chatService } from "@/services/chat";
import { cn } from "@/lib/utils";
interface ChatInputProps { interface ChatInputProps {
onSendMessage: (message: string, files?: File[]) => void; onSendMessage: (message: string, files?: File[]) => void;
draftMessage?: string | null;
draftTrigger?: number;
onDraftApplied?: () => void;
} }
export const ChatInput = ({ onSendMessage }: ChatInputProps) => { const MOBILE_TEXTAREA_HEIGHT = 88;
const DEFAULT_TEXTAREA_HEIGHT = 72;
export const ChatInput = ({
onSendMessage,
draftMessage,
draftTrigger,
onDraftApplied,
}: ChatInputProps) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [attachedFiles, setAttachedFiles] = useState<File[]>([]); const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [textareaHeight, setTextareaHeight] = useState(60); const [textareaHeight, setTextareaHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const dragStartY = useRef<number>(0); const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0); const dragStartHeight = useRef<number>(0);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true); setIsDragging(true);
dragStartY.current = e.clientY; dragStartY.current = e.clientY;
@@ -30,8 +52,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return; if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY; // Inverted: moving up = positive const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.min(400, Math.max(60, dragStartHeight.current + deltaY)); const newHeight = Math.min(400, Math.max(DEFAULT_TEXTAREA_HEIGHT, dragStartHeight.current + deltaY));
setTextareaHeight(newHeight); setTextareaHeight(newHeight);
}; };
@@ -40,16 +62,30 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
}; };
if (isDragging) { if (isDragging) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
} }
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isDragging, textareaHeight]); }, [isDragging, textareaHeight]);
useEffect(() => {
if (!draftMessage || draftTrigger == null) return;
setMessage(draftMessage);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
const end = draftMessage.length;
textarea.setSelectionRange(end, end);
});
onDraftApplied?.();
}, [draftMessage, draftTrigger, onDraftApplied]);
const handleSend = () => { const handleSend = () => {
if (message.trim() || attachedFiles.length > 0) { if (message.trim() || attachedFiles.length > 0) {
onSendMessage(message, attachedFiles); onSendMessage(message, attachedFiles);
@@ -67,21 +103,50 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const newFiles = [...attachedFiles, ...files];
// Validate file size (max 10MB per file) if (newFiles.length > chatService.getMaxAttachments()) {
const validFiles = files.filter((file) => { toast({
title: "Limite de anexos excedido",
description: `Você pode anexar no máximo ${chatService.getMaxAttachments()} arquivos por mensagem.`,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
const validFiles: File[] = [];
for (const file of files) {
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
toast({ toast({
title: "Arquivo muito grande", title: "Arquivo muito grande",
description: `${file.name} excede o limite de 10MB`, description: `${file.name} excede o limite de 10MB`,
variant: "destructive", variant: "destructive",
}); });
return false; continue;
} }
return true;
});
setAttachedFiles([...attachedFiles, ...validFiles]); validFiles.push(file);
}
const allFiles = [...attachedFiles, ...validFiles];
const validation = chatService.validateAttachments(allFiles);
if (!validation.valid) {
toast({
title: "Formato de arquivo não permitido",
description: validation.error,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setAttachedFiles(allFiles);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = "";
} }
@@ -91,95 +156,108 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
setAttachedFiles(attachedFiles.filter((_, i) => i !== index)); setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
}; };
const canSend = Boolean(message.trim() || attachedFiles.length > 0);
const textareaStyleHeight = isMobile ? MOBILE_TEXTAREA_HEIGHT : textareaHeight;
return ( return (
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-4"> <div className="w-full border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
<div className="max-w-4xl mx-auto space-y-3"> <div className="mx-auto w-full max-w-4xl space-y-2 px-2 md:space-y-3 md:px-4">
{/* Attached Files Preview */}
{attachedFiles.length > 0 && ( {attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{attachedFiles.map((file, index) => ( {attachedFiles.map((file, index) => (
<div <div
key={index} key={index}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted border border-border text-sm" className="flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/80 px-3 py-1.5 text-xs md:text-sm"
> >
<Paperclip className="w-3 h-3 text-primary" /> <Paperclip className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="font-medium">{file.name}</span> <span className="truncate font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground"> <span className="hidden text-xs text-muted-foreground md:inline">
({(file.size / 1024).toFixed(1)} KB) ({(file.size / 1024).toFixed(1)} KB)
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-4 w-4 ml-1" className="h-6 w-6 shrink-0"
onClick={() => removeFile(index)} onClick={() => removeFile(index)}
aria-label={`Remover ${file.name}`}
> >
<X className="w-3 h-3" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Message Input and Buttons */} <div className="glass-effect overflow-hidden rounded-2xl border border-border/60 shadow-sm">
<div className="flex gap-2 items-end">
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
{/* Resize Handle */}
<div <div
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
className={`w-full h-6 flex items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${ className={cn(
isDragging ? 'bg-primary/20' : '' "hidden h-5 w-full cursor-ns-resize items-center justify-center border-b border-border/40 transition-colors md:flex",
}`} isDragging ? "bg-primary/15" : "hover:bg-primary/8",
title="Arraste para cima para aumentar" )}
title="Arraste para aumentar a altura"
> >
<GripHorizontal className="w-5 h-5 text-muted-foreground" /> <GripHorizontal className="h-4 w-4 text-muted-foreground" />
</div> </div>
{/* Textarea */} <div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
<div className="px-3 pb-3">
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..." placeholder="Digite sua mensagem..."
style={{ height: `${textareaHeight}px` }} style={{ height: textareaStyleHeight }}
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto" className="min-h-0 w-full resize-none border-0 bg-transparent p-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 md:text-base"
/> />
</div> </div>
</div>
{/* Action Buttons */} <div className="flex items-center justify-between gap-2 border-t border-border/50 bg-muted/15 px-2 py-2 md:px-3 md:py-2.5">
<div className="flex gap-2"> <div className="flex min-w-0 items-center gap-1">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
className="hidden" className="hidden"
onChange={handleFileSelect} onChange={handleFileSelect}
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp" accept={chatService.getAllowedFileExtensions()}
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
/> />
<Button <Button
variant="outline" type="button"
variant="ghost"
size="icon" size="icon"
className="h-11 w-11 glass-effect" className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="Anexar arquivo" title="Anexar arquivo"
aria-label="Anexar arquivo"
> >
<Paperclip className="w-5 h-5" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
{attachedFiles.length > 0 && (
<span className="truncate text-xs text-muted-foreground">
{attachedFiles.length} anexo{attachedFiles.length > 1 ? "s" : ""}
</span>
)}
</div>
<Button <Button
type="button"
onClick={handleSend} onClick={handleSend}
size="icon" size="icon"
className="h-11 w-11 cyber-glow" disabled={!canSend}
disabled={!message.trim() && attachedFiles.length === 0} className="h-9 w-9 shrink-0 md:h-10 md:w-10"
title="Enviar mensagem"
aria-label="Enviar mensagem"
> >
<Send className="w-5 h-5" /> <Send className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
<p className="text-xs text-muted-foreground text-center"> <p className="hidden text-center text-xs text-muted-foreground md:block">
Pressione Enter para enviar, Shift + Enter para nova linha Máx. 10MB por arquivo Pressione Enter para enviar, Shift + Enter para nova linha Máx. {chatService.getMaxAttachments()}{" "}
anexos Formatos: {chatService.getAllowedFileTypesLabel()}
</p> </p>
</div> </div>
</div> </div>
+18 -13
View File
@@ -1,4 +1,5 @@
import { Bot, User, Copy, File } from "lucide-react"; import { Bot, User, Copy, File } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useState } from "react"; import { useState } from "react";
@@ -30,22 +31,22 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
return ( return (
<div <div
className={`flex gap-4 py-6 px-6 group hover:bg-muted/10 transition-colors ${ className={`flex gap-2 md:gap-4 py-4 md:py-6 px-3 md:px-6 group hover:bg-muted/10 transition-colors ${
isAssistant ? "justify-start" : "justify-end" isAssistant ? "justify-start" : "justify-end"
}`} }`}
onMouseEnter={() => setShowActions(true)} onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)} onMouseLeave={() => setShowActions(false)}
> >
{isAssistant && ( {isAssistant && (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-1 md:gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary"> <div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
<Bot className="w-5 h-5 text-primary-foreground" /> <Bot className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
</div> </div>
{model && ( {model && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30" className="hidden md:block text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
> >
{model} {model}
</Badge> </Badge>
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div> </div>
)} )}
<div className={`flex-1 max-w-3xl space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}> <div className={`flex-1 max-w-4xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
{/* Attachments */} {/* Attachments */}
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2"> <div className="flex flex-wrap gap-2 mb-2">
@@ -73,15 +74,19 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
)} )}
<div <div
className={`inline-block px-4 py-3 rounded-xl ${ className={`inline-block max-w-[90%] px-3 md:px-4 py-2 md:py-3 rounded-xl break-words ${
isAssistant isAssistant
? "bg-card border border-border" ? "bg-card border border-border text-foreground"
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground" : "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
}`} }`}
> >
<p className={`leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}> {isAssistant ? (
{content} <div className="prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-p:leading-relaxed">
</p> <ReactMarkdown>{content}</ReactMarkdown>
</div>
) : (
<p className="text-sm md:text-base leading-relaxed text-white">{content}</p>
)}
</div> </div>
{showActions && ( {showActions && (
@@ -100,8 +105,8 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div> </div>
{!isAssistant && ( {!isAssistant && (
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent"> <div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
<User className="w-5 h-5 text-accent" /> <User className="w-4 h-4 md:w-5 md:h-5 text-accent" />
</div> </div>
)} )}
</div> </div>
+446 -297
View File
@@ -1,12 +1,30 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import {
Search,
Folder,
FolderPlus,
MessageSquare,
MoreVertical,
Trash2,
Edit,
ChevronLeft,
ChevronRight,
ChevronDown,
Plus,
Inbox,
} from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { chatService, ChatRecord, FolderRecord } from "@/services/chat";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@@ -29,417 +47,556 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
folderId?: string;
}
interface ChatFolder {
id: string;
name: string;
}
interface ChatSidebarProps { interface ChatSidebarProps {
isCollapsed: boolean; isCollapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
onNewChat: () => void; onNewChat: () => void;
onSelectChat?: (chat: ChatRecord) => void;
currentChatId?: string;
} }
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => { const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<p className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{children}
</p>
);
export const ChatSidebar = ({
isCollapsed,
onToggleCollapse,
onNewChat,
onSelectChat,
currentChatId,
}: ChatSidebarProps) => {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false); const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState(""); const [newFolderName, setNewFolderName] = useState("");
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null); const [renamingFolder, setRenamingFolder] = useState<FolderRecord | null>(null);
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null); const [renamedFolderName, setRenamedFolderName] = useState("");
const [selectedConversation, setSelectedConversation] = useState<string | null>("1"); const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [folders, setFolders] = useState<ChatFolder[]>([ const [chats, setChats] = useState<ChatRecord[]>([]);
{ id: "work", name: "Trabalho" }, const [folders, setFolders] = useState<FolderRecord[]>([]);
{ id: "personal", name: "Pessoal" }, /** Pastas fechadas por padrão; estado preservado ao recarregar lista */
]); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [conversations, setConversations] = useState<Conversation[]>([ useEffect(() => {
{ loadChatsAndFolders();
id: "1",
title: "Conversa sobre IA",
lastMessage: "Como funciona o machine learning?",
timestamp: "Há 5 min",
},
{
id: "2",
title: "Projeto HGTX",
lastMessage: "Discutindo arquitetura do sistema",
timestamp: "Há 1 hora",
folderId: "work",
},
{
id: "3",
title: "Ideias criativas",
lastMessage: "Brainstorm para novo produto",
timestamp: "Ontem",
folderId: "personal",
},
]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"])); const handleChatUpdate = () => {
loadChatsAndFolders();
const handleCreateFolder = () => {
if (newFolderName.trim()) {
const newFolder: ChatFolder = {
id: Date.now().toString(),
name: newFolderName,
}; };
setFolders([...folders, newFolder]);
window.addEventListener("chatUpdated", handleChatUpdate);
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
}, []);
const loadChatsAndFolders = async () => {
setIsLoading(true);
try {
const data = await chatService.getChatsAndFolders();
setChats(data.chats);
setFolders(data.folders);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
console.error("Erro ao carregar chats e pastas:", error);
toast({
title: "Erro ao carregar dados",
description: message,
variant: "destructive",
});
setChats([]);
setFolders([]);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim()) return;
const name = newFolderName.trim();
try {
await chatService.createFolder(name);
await loadChatsAndFolders();
setNewFolderName(""); setNewFolderName("");
setIsCreateFolderOpen(false); setIsCreateFolderOpen(false);
toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível criar a pasta.";
toast({ title: "Erro ao criar pasta", description: message, variant: "destructive" });
} }
}; };
const handleEditFolder = () => { const handleDeleteFolder = async () => {
if (editingFolder && newFolderName.trim()) { if (!deletingFolder) return;
setFolders( try {
folders.map((f) => await chatService.deleteFolder(deletingFolder.id);
f.id === editingFolder.id ? { ...f, name: newFolderName } : f setExpandedFolders((prev) => {
) const next = new Set(prev);
); next.delete(deletingFolder.id);
setNewFolderName(""); return next;
setEditingFolder(null); });
setIsEditFolderOpen(false); await loadChatsAndFolders();
}
};
const handleDeleteFolder = () => {
if (deletingFolder) {
// Remove folder and move conversations back to "no folder"
setConversations(
conversations.map((conv) =>
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
)
);
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
setDeletingFolder(null); setDeletingFolder(null);
setIsDeleteFolderOpen(false); setIsDeleteFolderOpen(false);
toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
} }
}; };
const openEditFolder = (folder: ChatFolder) => { const openDeleteFolder = (folder: FolderRecord) => {
setEditingFolder(folder);
setNewFolderName(folder.name);
setIsEditFolderOpen(true);
};
const openDeleteFolder = (folder: ChatFolder) => {
setDeletingFolder(folder); setDeletingFolder(folder);
setIsDeleteFolderOpen(true); setIsDeleteFolderOpen(true);
}; };
const toggleFolder = (folderId: string) => { const openRenameFolder = (folder: FolderRecord) => {
const newExpanded = new Set(expandedFolders); setRenamingFolder(folder);
if (newExpanded.has(folderId)) { setRenamedFolderName(folder.name);
newExpanded.delete(folderId); setIsRenameFolderOpen(true);
} else { };
newExpanded.add(folderId);
const handleRenameFolder = async () => {
if (!renamingFolder || !renamedFolderName.trim()) return;
try {
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
await loadChatsAndFolders();
setRenamingFolder(null);
setRenamedFolderName("");
setIsRenameFolderOpen(false);
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
} }
setExpandedFolders(newExpanded);
}; };
const moveToFolder = (conversationId: string, folderId: string) => { const toggleFolder = (folderId: string) => {
setConversations( setExpandedFolders((prev) => {
conversations.map((conv) => const next = new Set(prev);
conv.id === conversationId ? { ...conv, folderId } : conv if (next.has(folderId)) next.delete(folderId);
) else next.add(folderId);
); return next;
});
}; };
const removeFromFolder = (conversationId: string) => { const moveToFolder = async (chatId: string, folderId: string) => {
setConversations( try {
conversations.map((conv) => await chatService.moveChatToFolder(chatId, folderId);
conv.id === conversationId ? { ...conv, folderId: undefined } : conv await loadChatsAndFolders();
) const folder = folders.find((f) => f.id === folderId);
); toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
}
}; };
const filteredConversations = conversations.filter( const handleDeleteChat = async () => {
(conv) => if (!deletingChat) return;
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) || try {
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase()) await chatService.deleteChat(deletingChat.id);
await loadChatsAndFolders();
setDeletingChat(null);
setIsDeleteChatOpen(false);
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
}
};
const openDeleteChat = (chat: ChatRecord) => {
setDeletingChat(chat);
setIsDeleteChatOpen(true);
};
const handleSelectChat = (chat: ChatRecord) => {
onSelectChat?.(chat);
};
const filteredChats = chats.filter((chat) =>
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
); );
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId); const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
const conversationsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id); const chatsByFolder = folders.reduce(
(acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
return acc; return acc;
}, {} as Record<string, Conversation[]>); },
{} as Record<string, ChatRecord[]>
);
const visibleFolders = folders.filter(
(folder) =>
(chatsByFolder[folder.id]?.length ?? 0) > 0 ||
!searchQuery.trim() ||
folder.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isCollapsed) { if (isCollapsed) {
return ( return (
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4"> <div className="flex h-full w-12 flex-col items-center border-r border-border bg-sidebar/50 py-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
title="Expandir conversas"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
); );
} }
return ( return (
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col"> <div className="flex h-full w-80 max-w-80 min-w-0 shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar/40">
{/* Header with collapse button */} {/* Cabeçalho */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between"> <div className="flex min-w-0 shrink-0 items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">Conversas</h3> <h3 className="text-sm font-semibold text-foreground">Conversas</h3>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-8 w-8 text-muted-foreground hover:text-foreground"
title="Recolher"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Search */} {/* Busca + ações */}
<div className="px-4 pb-3 border-b border-border space-y-3"> <div className="min-w-0 shrink-0 space-y-2 border-b border-border px-3 py-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Pesquisar conversas..." placeholder="Pesquisar conversas..."
className="pl-9 bg-muted/50" className="h-9 bg-background/80 pl-9 text-sm"
/> />
</div> </div>
<Button className="w-full gap-2 cyber-glow" variant="default" onClick={onNewChat}> <div className="grid grid-cols-2 gap-2">
<Plus className="w-4 h-4" /> <Button className="h-9 gap-1.5 text-sm" variant="default" onClick={onNewChat}>
Novo Chat <Plus className="h-4 w-4" />
Novo chat
</Button> </Button>
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}> <Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full gap-2" size="sm"> <Button variant="outline" className="h-9 gap-1.5 text-sm">
<FolderPlus className="w-4 h-4" /> <FolderPlus className="h-4 w-4" />
Nova Pasta Pasta
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="glass-effect bg-card border-border z-50"> <DialogContent className="border-border bg-card">
<DialogHeader> <DialogHeader>
<DialogTitle>Criar Nova Pasta</DialogTitle> <DialogTitle>Nova pasta</DialogTitle>
<DialogDescription> <DialogDescription>Organize suas conversas em pastas.</DialogDescription>
Organize suas conversas em pastas personalizadas.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-2 py-2">
<div className="space-y-2"> <Label htmlFor="folder-name">Nome</Label>
<Label htmlFor="folder-name">Nome da Pasta</Label>
<Input <Input
id="folder-name" id="folder-name"
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ex: Projetos, Estudos..." placeholder="Ex: Projetos, Clientes..."
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()} onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/> />
</div> </div>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}> <Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}> <Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
Criar Pasta Criar
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</div>
{/* Edit Folder Dialog */} <Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}> <DialogContent className="border-border bg-card">
<DialogContent className="glass-effect bg-card border-border z-50">
<DialogHeader> <DialogHeader>
<DialogTitle>Editar Pasta</DialogTitle> <DialogTitle>Renomear pasta</DialogTitle>
<DialogDescription> <DialogDescription>
Renomeie sua pasta de conversas. Novo nome para &quot;{renamingFolder?.name}&quot;.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-2 py-2">
<div className="space-y-2"> <Label htmlFor="rename-folder-name">Nome</Label>
<Label htmlFor="edit-folder-name">Nome da Pasta</Label>
<Input <Input
id="edit-folder-name" id="rename-folder-name"
value={newFolderName} value={renamedFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setRenamedFolderName(e.target.value)}
placeholder="Digite o novo nome..." onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
onKeyDown={(e) => e.key === "Enter" && handleEditFolder()} autoFocus
/> />
</div> </div>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsEditFolderOpen(false)}> <Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}> <Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
Salvar Salvar
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete Folder Alert */}
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}> <AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50"> <AlertDialogContent className="border-border bg-card">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle> <AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. As conversas dentro da pasta serão movidas para "Sem Pasta". A pasta &quot;{deletingFolder?.name}&quot; será excluída. As conversas dentro dela
ficarão sem pasta.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteFolder} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> <AlertDialogAction
onClick={handleDeleteFolder}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Lista */}
<ScrollArea className="min-h-0 w-full min-w-0 flex-1 overflow-hidden">
<div className="box-border w-full max-w-full min-w-0 space-y-1 overflow-x-hidden p-2">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
</div>
) : (
<>
{visibleFolders.length > 0 && (
<section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
{visibleFolders.map((folder) => {
const folderChats = chatsByFolder[folder.id] ?? [];
const isExpanded = expandedFolders.has(folder.id);
return (
<div key={folder.id} className="min-w-0">
<div className="flex min-w-0 items-center">
<button
type="button"
onClick={() => toggleFolder(folder.id)}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent/50"
aria-expanded={isExpanded}
>
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
!isExpanded && "-rotate-90"
)}
/>
<Folder className="h-4 w-4 shrink-0 text-primary/90" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{folder.name}
</span>
<span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
{folderChats.length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
<DropdownMenuItem className="gap-2" onClick={() => openRenameFolder(folder)}>
<Edit className="h-3.5 w-3.5" />
Renomear
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => openDeleteFolder(folder)}
>
<Trash2 className="h-3.5 w-3.5" />
Excluir pasta
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{isExpanded && (
<div className="min-w-0 space-y-0.5 py-0.5 pl-5">
{folderChats.length > 0 ? (
folderChats.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onDelete={() => openDeleteChat(chat)}
nested
/>
))
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
)}
</div>
)}
</div>
);
})}
</section>
)}
{chatsWithoutFolder.length > 0 && (
<section
className={cn(
"w-full min-w-0 max-w-full overflow-hidden",
visibleFolders.length > 0 && "mt-3 border-t border-border/40 pt-3"
)}
>
<SectionLabel>Conversas</SectionLabel>
<div className="min-w-0 space-y-0.5">
{chatsWithoutFolder.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
</section>
)}
{!isLoading && chats.length === 0 && (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
<Inbox className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">Nenhuma conversa</p>
<p className="mt-1 text-xs text-muted-foreground">
Crie um novo chat para começar
</p>
</div>
)}
{!isLoading &&
chats.length > 0 &&
filteredChats.length === 0 &&
searchQuery.trim() && (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
Nenhum resultado para &quot;{searchQuery}&quot;
</p>
)}
</>
)}
</div>
</ScrollArea>
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
<AlertDialogContent className="border-border bg-card">
<AlertDialogHeader>
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
<AlertDialogDescription>
&quot;{deletingChat?.title}&quot; será excluída permanentemente.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteChat}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Excluir Excluir
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
{/* Conversations List */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{/* Folders */}
{folders.map((folder) => (
<div key={folder.id} className="space-y-1">
<div className="flex items-center gap-1">
<button
onClick={() => toggleFolder(folder.id)}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
<Folder className="w-4 h-4 text-white" />
</div>
<span className="font-medium text-sm flex-1 text-left">
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{conversationsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem
className="gap-2"
onClick={() => openEditFolder(folder)}
>
<Edit className="w-3 h-3" />
Editar Nome
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={() => openDeleteFolder(folder)}
>
<Trash2 className="w-3 h-3" />
Excluir Pasta
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
<div className="ml-6 space-y-1">
{conversationsByFolder[folder.id].map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
/>
))}
</div>
)}
</div>
))}
{/* Conversations without folder */}
{conversationsWithoutFolder.length > 0 && (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Sem Pasta
</div>
{conversationsWithoutFolder.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
/>
))}
</div>
)}
</div>
</ScrollArea>
</div>
); );
}; };
interface ConversationItemProps { interface ChatItemProps {
conversation: Conversation; chat: ChatRecord;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
folders: ChatFolder[]; folders: FolderRecord[];
onMoveToFolder: (conversationId: string, folderId: string) => void; onMoveToFolder: (chatId: string, folderId: string) => void;
onRemoveFromFolder: (conversationId: string) => void; onDelete: () => void;
nested?: boolean;
} }
const ConversationItem = ({ const ChatItem = ({
conversation, chat,
isSelected, isSelected,
onSelect, onSelect,
folders, folders,
onMoveToFolder, onMoveToFolder,
onRemoveFromFolder, onDelete,
}: ConversationItemProps) => { nested = false,
}: ChatItemProps) => {
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
addSuffix: true,
locale: ptBR,
});
return ( return (
<div <div
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${ role="button"
isSelected tabIndex={0}
? "bg-sidebar-accent cyber-border"
: "hover:bg-muted/50"
}`}
onClick={onSelect} onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
className={cn(
"group flex w-full max-w-full min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 transition-colors",
nested && "py-1",
isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50"
)}
> >
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" /> <MessageSquare
<div className="flex-1 min-w-0"> className={cn(
<p className="text-sm font-medium truncate">{conversation.title}</p> "h-4 w-4 shrink-0",
<p className="text-xs text-muted-foreground truncate"> isSelected ? "text-primary" : "text-muted-foreground"
{conversation.lastMessage} )}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-snug" title={chat.title}>
{chat.title}
</p> </p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
{conversation.timestamp} {timeAgo}
</p> </p>
</div> </div>
@@ -448,40 +605,32 @@ const ConversationItem = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity" className="h-7 w-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="Opções da conversa"
> >
<MoreVertical className="w-3 h-3" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50"> <DropdownMenuContent align="end" className="border-border bg-popover">
<DropdownMenuItem className="gap-2"> {folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
<Edit className="w-3 h-3" /> <>
Renomear {folders
</DropdownMenuItem> .filter((f) => f.id !== chat.folder_id)
.map((folder) => (
{conversation.folderId && (
<DropdownMenuItem
className="gap-2"
onClick={() => onRemoveFromFolder(conversation.id)}
>
<FolderInput className="w-3 h-3" />
Remover da Pasta
</DropdownMenuItem>
)}
{folders.map((folder) => (
<DropdownMenuItem <DropdownMenuItem
key={folder.id} key={folder.id}
className="gap-2" className="gap-2"
onClick={() => onMoveToFolder(conversation.id, folder.id)} onClick={() => onMoveToFolder(chat.id, folder.id)}
> >
<Folder className="w-3 h-3" /> <Folder className="h-3.5 w-3.5" />
Mover para {folder.name} Mover para {folder.name}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2 text-destructive"> </>
<Trash2 className="w-3 h-3" /> )}
<DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
Excluir Excluir
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+202 -48
View File
@@ -1,52 +1,138 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader"; import { ChatHeader } from "@/components/ChatHeader";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput"; import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar"; import { ChatSidebar } from "./ChatSidebar";
import { ChatWelcome } from "./ChatWelcome";
import {
createWelcomeMessage,
isWelcomeMessage,
withoutWelcomeMessages,
} from "./welcome-message";
import type { Message } from "./ChatView.types";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
import { useToast } from "@/hooks/use-toast";
import { getModelId } from "@/config/models";
interface Message { // Texto padrão da personalidade (usado para novos chats)
id: string; const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
role: "user" | "assistant";
content: string;
model?: string;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}
export const ChatView = () => { export const ChatView = () => {
const { toast } = useToast();
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false); const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1"); const [selectedModel, setSelectedModel] = useState("GPT-4o");
const [currentChatId, setCurrentChatId] = useState("1"); // Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo."); const [currentChatId, setCurrentChatId] = useState("0");
const [messages, setMessages] = useState<Message[]>([ // Inicia com texto padrão - será atualizado ao carregar chat existente
{ const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
id: "1", const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: "ChatGPT 4.1",
},
]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [inputDraft, setInputDraft] = useState<{
text: string;
at: number;
} | null>(null);
const handleNewChat = () => { const handleSelectSuggestion = (prompt: string) => {
const newChatId = Date.now().toString(); setInputDraft({ text: prompt, at: Date.now() });
setCurrentChatId(newChatId);
setMessages([
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: selectedModel,
},
]);
}; };
const handleSendMessage = (content: string, files?: File[]) => { // NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
// quando enviadas via handleSendMessage
// useEffect(() => {
// if (messages.length > 1) {
// saveCurrentChat();
// window.dispatchEvent(new Event('chatUpdated'));
// }
// }, [messages]);
// NOTA: Função de salvamento localStorage desabilitada - migrado para banco de dados
// const saveCurrentChat = () => {
// try {
// const chatTitle = chatService.generateChatTitle(
// messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
// );
// const storedChat: StoredChat = {
// id: currentChatId,
// title: chatTitle,
// createdAt: new Date(),
// updatedAt: new Date(),
// model: selectedModel,
// systemPrompt: systemPrompt,
// messages: messages.map(msg => ({
// ...msg,
// timestamp: new Date(),
// })),
// };
// chatService.saveChat(storedChat);
// } catch (error) {
// console.error('Erro ao salvar chat:', error);
// }
// };
const handleNewChat = () => {
// Reseta para "0" - novo chat sempre começa com chat_id "0"
setCurrentChatId("0");
// Reseta a personalidade para o texto padrão
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
setMessages([createWelcomeMessage()]);
// Dispara evento para atualizar a sidebar
window.dispatchEvent(new Event('chatUpdated'));
};
const handleLoadChat = async (chat: ChatRecord) => {
// Carrega um chat existente do banco de dados
setCurrentChatId(chat.id);
// Atualiza a personalidade com o valor do banco de dados
// Se não tiver personalidade salva, usa o texto padrão
setSystemPrompt(chat.personalidade || DEFAULT_SYSTEM_PROMPT);
// Limpa mensagens enquanto carrega
setMessages([]);
setIsLoading(true);
try {
// Busca mensagens do chat na API
const messagesFromAPI = await chatService.getChatMessages(chat.id);
// Converte MessageRecord para Message
const loadedMessages: Message[] = messagesFromAPI.map((msg: MessageRecord) => ({
id: msg.id,
role: msg.role,
content: msg.content,
model: msg.model_name, // Nome do modelo retornado pela API
attachments: msg.has_attachments ? [] : undefined, // Não temos detalhes dos anexos no GET
}));
setMessages(loadedMessages);
toast({
title: "Chat carregado",
description: `${loadedMessages.length} mensagens carregadas.`,
});
} catch (error: any) {
console.error('Erro ao carregar mensagens:', error);
toast({
title: "Erro ao carregar chat",
description: error.message || "Não foi possível carregar as mensagens.",
variant: "destructive",
});
// Inicia com mensagem padrão em caso de erro
setMessages([createWelcomeMessage()]);
} finally {
setIsLoading(false);
}
};
const handleSendMessage = async (content: string, files?: File[]) => {
// Adiciona mensagem do usuário na interface
const newMessage: Message = { const newMessage: Message = {
id: Date.now().toString(), id: Date.now().toString(),
role: "user", role: "user",
@@ -58,34 +144,90 @@ export const ChatView = () => {
})), })),
}; };
setMessages((prev) => [...prev, newMessage]); setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
setIsLoading(true); setIsLoading(true);
// Simulate AI response try {
setTimeout(() => { // Envia mensagem para a API
// Converte o nome do modelo para o ID da API
const modelId = getModelId(selectedModel);
const response = await chatService.sendMessage({
chatId: currentChatId,
message: content,
modelId: modelId,
personalidade: systemPrompt,
anexos: files,
});
// Adiciona resposta da IA na interface
if (response.success) {
// IMPORTANTE: Atualiza o chat_id com o valor retornado pela API
// Isso mantém o contexto da conversa para as próximas mensagens
if (response.chat_id && response.chat_id !== currentChatId) {
console.log(`Chat ID atualizado: ${currentChatId}${response.chat_id}`);
// Se estava com chat_id "0", significa que é a primeira mensagem
// e o chat acabou de ser criado no backend
const isFirstMessage = currentChatId === "0";
setCurrentChatId(response.chat_id);
// Dispara evento para ChatSidebar recarregar e mostrar o novo chat
if (isFirstMessage) {
console.log('Primeira mensagem - novo chat criado, atualizando sidebar');
window.dispatchEvent(new Event('chatUpdated'));
}
}
const aiResponse: Message = { const aiResponse: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: "assistant", role: "assistant",
content: content: response.response,
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
model: selectedModel, model: selectedModel,
}; };
setMessages((prev) => [...prev, aiResponse]); setMessages((prev) => [...prev, aiResponse]);
} else {
throw new Error('Resposta inválida da API');
}
} catch (error: any) {
console.error('Erro ao enviar mensagem:', error);
// Mostra toast de erro para o usuário
toast({
title: "Erro ao enviar mensagem",
description: error.message || "Não foi possível comunicar com a IA. Tente novamente.",
variant: "destructive",
});
// Adiciona mensagem de erro na interface
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
model: selectedModel,
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false); setIsLoading(false);
}, 1500); }
}; };
return ( return (
<div className="flex h-full"> <div className="flex h-full w-full pb-16 md:pb-0">
{/* Chat Sidebar */} {/* Chat Sidebar - Hidden on mobile */}
<div className="hidden w-80 max-w-80 shrink-0 min-w-0 overflow-hidden md:block">
<ChatSidebar <ChatSidebar
isCollapsed={isChatSidebarCollapsed} isCollapsed={isChatSidebarCollapsed}
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)} onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
onNewChat={handleNewChat} onNewChat={handleNewChat}
onSelectChat={handleLoadChat}
currentChatId={currentChatId}
/> />
</div>
{/* Main Chat Area */} {/* Main Chat Area */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col w-full">
<ChatHeader <ChatHeader
showModelSelector={true} showModelSelector={true}
selectedModel={selectedModel} selectedModel={selectedModel}
@@ -95,8 +237,14 @@ export const ChatView = () => {
/> />
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto px-2 md:px-4">
{messages.map((message) => ( {messages.map((message) =>
isWelcomeMessage(message) ? (
<ChatWelcome
key={message.id}
onSelectSuggestion={handleSelectSuggestion}
/>
) : (
<ChatMessage <ChatMessage
key={message.id} key={message.id}
role={message.role} role={message.role}
@@ -104,7 +252,8 @@ export const ChatView = () => {
model={message.model} model={message.model}
attachments={message.attachments} attachments={message.attachments}
/> />
))} )
)}
{isLoading && ( {isLoading && (
<div className="flex gap-4 py-6 px-6 bg-muted/30"> <div className="flex gap-4 py-6 px-6 bg-muted/30">
@@ -121,7 +270,12 @@ export const ChatView = () => {
</div> </div>
</ScrollArea> </ScrollArea>
<ChatInput onSendMessage={handleSendMessage} /> <ChatInput
onSendMessage={handleSendMessage}
draftMessage={inputDraft?.text}
draftTrigger={inputDraft?.at}
onDraftApplied={() => setInputDraft(null)}
/>
</div> </div>
</div> </div>
); );
+11
View File
@@ -0,0 +1,11 @@
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
model?: string;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}
+85
View File
@@ -0,0 +1,85 @@
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import {
CHAT_STARTER_SUGGESTIONS,
COMMANDER_REVEAL_COLORS,
} from "./welcome-message";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
interface ChatWelcomeProps {
className?: string;
onSelectSuggestion?: (prompt: string) => void;
}
export function ChatWelcome({ className, onSelectSuggestion }: ChatWelcomeProps) {
return (
<div
className={cn(
"flex min-h-[min(420px,55vh)] flex-col items-center justify-center px-4 py-10 text-center md:px-6 md:py-16",
className
)}
>
<div className="mb-2 h-1 w-12 rounded-full bg-primary/40" aria-hidden />
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
<DiaTextReveal
text="HGTX Commander"
colors={COMMANDER_REVEAL_COLORS}
textColor="hsl(var(--foreground))"
duration={1.35}
className="text-inherit"
/>
</h2>
<p className="max-w-md text-base leading-relaxed text-muted-foreground md:text-lg">
<DiaTextReveal
text="Como posso ajudá-lo hoje?"
colors={COMMANDER_REVEAL_COLORS}
textColor="hsl(var(--muted-foreground))"
duration={1.15}
delay={0.4}
className="text-inherit"
/>
</p>
{onSelectSuggestion && (
<div className="mt-8 w-full max-w-2xl animate-fade-in space-y-3">
<div className="flex items-center justify-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-primary/80" aria-hidden />
<AnimatedShinyText
className="mx-0 max-w-none text-xs font-semibold uppercase tracking-wider text-muted-foreground"
shimmerWidth={120}
>
Ideias para começar
</AnimatedShinyText>
</div>
<div className="flex flex-wrap items-stretch justify-center gap-2">
{CHAT_STARTER_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion.id}
type="button"
onClick={() => onSelectSuggestion(suggestion.prompt)}
title={suggestion.prompt}
className={cn(
"max-w-full rounded-full border border-border/80 bg-background/70 px-4 py-2.5 text-left text-sm font-medium text-foreground",
"shadow-sm transition-all duration-200",
"hover:border-primary/40 hover:bg-sidebar-accent/60 hover:shadow-md",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"active:scale-[0.98]"
)}
>
<span className="line-clamp-2">{suggestion.label}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground/80">
Toque em uma sugestão para preencher a mensagem você pode editar antes de enviar.
</p>
</div>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Message } from "./ChatView.types";
export const CHAT_WELCOME_MESSAGE_ID = "welcome";
/** Paleta alinhada ao azul HGTX Commander */
export const COMMANDER_REVEAL_COLORS = [
"#003366",
"#0055a4",
"#0066b3",
"#4d9de0",
"#a8c8f0",
];
export function createWelcomeMessage(): Message {
return {
id: CHAT_WELCOME_MESSAGE_ID,
role: "assistant",
content: "",
};
}
export function isWelcomeMessage(message: Message): boolean {
return message.id === CHAT_WELCOME_MESSAGE_ID;
}
export function withoutWelcomeMessages(messages: Message[]): Message[] {
return messages.filter((m) => !isWelcomeMessage(m));
}
export type ChatStarterSuggestion = {
id: string;
label: string;
prompt: string;
};
export const CHAT_STARTER_SUGGESTIONS: ChatStarterSuggestion[] = [
{
id: "executive-summary",
label: "Resumo com pendências",
prompt:
"Vou colar um texto ou documento abaixo. Produza um resumo executivo em tópicos, separe fatos de interpretações e liste pendências ou riscos que exijam decisão.",
},
{
id: "meeting-minutes",
label: "Ata da reunião",
prompt:
"Com base no conteúdo que enviarei, redija uma ata executiva: contexto em uma linha, decisões tomadas, responsáveis, prazos e pontos que ficaram em aberto.",
},
{
id: "audio-structure",
label: "Organizar transcrição",
prompt:
"Tenho uma transcrição de áudio para colar em seguida. Organize por tópicos cronológicos, destaque compromissos assumidos e sugira follow-ups que não foram fechados na conversa.",
},
{
id: "structured-reasoning",
label: "Linha de raciocínio",
prompt:
"Preciso estruturar um raciocínio sobre o tema que descreverei. Monte hipóteses, argumentos a favor e contra, e indique quais informações ainda faltam para fechar uma conclusão sólida.",
},
{
id: "compare-options",
label: "Comparar alternativas",
prompt:
"Vou descrever duas ou mais alternativas em seguida. Compare critérios objetivos, trade-offs e riscos de cada uma, e indique em que cenário cada opção faz mais sentido — sem favorecer uma resposta só pelo tom.",
},
{
id: "professional-draft",
label: "Comunicado profissional",
prompt:
"Com os pontos que enviarei abaixo, redija um e-mail ou comunicado interno: tom profissional e direto, assunto sugerido, corpo estruturado e uma ação clara no encerramento.",
},
];
@@ -0,0 +1,449 @@
import { useMemo, useState, type ChangeEvent } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Copy, Download, FileText, Loader2, Plus, Upload, X } from "lucide-react";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { toast } from "sonner";
import { commanderCsvService, type CommanderCsvMode } from "@/services/commanderCsv";
type HeaderItem = {
original: string;
slug: string;
};
const toSlug = (value: string) =>
value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
export function CommanderCsvView() {
const [mode, setMode] = useState<CommanderCsvMode>("pdf");
const [headerInput, setHeaderInput] = useState("");
const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHealthLoading, setIsHealthLoading] = useState(false);
const [healthStatus, setHealthStatus] = useState<"idle" | "ok" | "error">("idle");
const [responseData, setResponseData] = useState<unknown>(null);
const [isClearHeadersOpen, setIsClearHeadersOpen] = useState(false);
const [lastSentAt, setLastSentAt] = useState<Date | null>(null);
const [lastSentMode, setLastSentMode] = useState<CommanderCsvMode | null>(null);
const headersCsv = useMemo(() => headers.map((header) => header.slug).join(","), [headers]);
const addHeader = () => {
const original = headerInput.trim();
if (!original) return;
const slug = toSlug(original);
if (!slug) {
toast.error("Cabecalho invalido. Digite letras ou numeros.");
return;
}
if (headers.some((header) => header.slug === slug)) {
toast.error("Esse cabecalho ja foi adicionado.");
return;
}
setHeaders((prev) => [...prev, { original, slug }]);
setHeaderInput("");
};
const removeHeader = (slug: string) => {
setHeaders((prev) => prev.filter((header) => header.slug !== slug));
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
const fileName = file.name.toLowerCase();
if (!fileName.endsWith(".pdf")) {
toast.error("Envie apenas arquivo PDF.");
return;
}
setSelectedFile(file);
};
const runHealthcheck = async () => {
setIsHealthLoading(true);
try {
await commanderCsvService.healthcheck();
setHealthStatus("ok");
toast.success("API online.");
} catch (error: unknown) {
setHealthStatus("error");
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Falha no healthcheck.";
toast.error(message);
} finally {
setIsHealthLoading(false);
}
};
const submit = async () => {
if (headers.length === 0) {
toast.error("Adicione pelo menos um cabecalho.");
return;
}
if (mode === "pdf" && !selectedFile) {
toast.error("Selecione um PDF para enviar.");
return;
}
if (mode === "text" && !content.trim()) {
toast.error("Preencha o conteudo de texto.");
return;
}
setIsSubmitting(true);
try {
const result = mode === "pdf"
? await commanderCsvService.pdf2csv(selectedFile as File, headersCsv)
: await commanderCsvService.txt2csv(content.trim(), headersCsv);
setResponseData(result);
setLastSentAt(new Date());
setLastSentMode(mode);
toast.success("Processamento concluido.");
} catch (error: unknown) {
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Erro ao enviar dados para API.";
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const responseContent = useMemo(() => {
if (responseData && typeof responseData === "object" && "content" in responseData) {
const content = (responseData as { content?: unknown }).content;
if (typeof content === "string") return content;
}
return null;
}, [responseData]);
const copyResponse = async () => {
const textToCopy = responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "");
if (!textToCopy) {
toast.error("Nao ha resposta para copiar.");
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Resposta copiada.");
} catch {
toast.error("Falha ao copiar resposta.");
}
};
const downloadCsv = () => {
if (!responseContent) {
toast.error("Nao ha CSV para baixar.");
return;
}
const utf8Bom = "\uFEFF";
const blob = new Blob([utf8Bom, responseContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `commander-csv-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.csv`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.success("Download do CSV iniciado.");
};
const sendBlockReason = useMemo(() => {
if (headers.length === 0) return "Adicione pelo menos um cabecalho.";
if (mode === "pdf" && !selectedFile) return "Selecione um PDF para envio.";
if (mode === "text" && !content.trim()) return "Preencha o conteudo de texto.";
return null;
}, [headers.length, mode, selectedFile, content]);
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-xl md:text-3xl font-bold">Commander CSV</h1>
<p className="text-sm text-muted-foreground">
Configure os cabecalhos, escolha o modo de envio e gere o CSV com mais controle.
</p>
</div>
<div className="flex items-center gap-2">
<div
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold ${
healthStatus === "ok"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: healthStatus === "error"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-border bg-muted/40 text-muted-foreground"
}`}
>
<span
className={`h-2 w-2 rounded-full ${
healthStatus === "ok"
? "bg-emerald-500 animate-online-pulse"
: healthStatus === "error"
? "bg-destructive"
: "bg-muted-foreground/70"
}`}
/>
{healthStatus === "ok" ? "API online" : healthStatus === "error" ? "API indisponivel" : "Status da API"}
</div>
<Button variant="outline" onClick={runHealthcheck} disabled={isHealthLoading}>
{isHealthLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Healthcheck
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="mx-auto w-full max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Cabecalhos</CardTitle>
<CardDescription>
Defina os campos do CSV. O sistema gera slug automaticamente em minusculo com underline.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Input
value={headerInput}
onChange={(event) => setHeaderInput(event.target.value)}
placeholder="Ex: Data de Aniversario"
className="min-w-[220px] flex-1"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addHeader();
}
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsClearHeadersOpen(true)}
disabled={headers.length === 0}
>
Limpar todos
</Button>
<Button type="button" onClick={addHeader} disabled={!headerInput.trim()}>
<Plus className="w-4 h-4 mr-1" />
Adicionar
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-3">
{headers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Adicione seu primeiro cabecalho para habilitar o envio.
</p>
) : (
headers.map((header) => (
<div
key={header.slug}
className="group inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 shadow-sm transition-colors hover:border-primary/40 hover:bg-primary/10"
>
<p className="text-xs font-semibold text-foreground">{header.slug}</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeHeader(header.slug)}
title="Remover cabecalho"
className="h-5 w-5 rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Envio</CardTitle>
<CardDescription>
Alterne entre PDF ou Texto com os mesmos cabecalhos configurados.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as CommanderCsvMode)}
className="grid gap-3 sm:grid-cols-2"
>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="pdf" id="csv-mode-pdf" />
<div>
<p className="text-sm font-medium">Modo PDF</p>
<p className="text-xs text-muted-foreground">Upload de arquivo PDF + headers</p>
</div>
</label>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="text" id="csv-mode-text" />
<div>
<p className="text-sm font-medium">Modo Texto</p>
<p className="text-xs text-muted-foreground">Conteudo em texto + headers</p>
</div>
</label>
</RadioGroup>
{mode === "pdf" ? (
<div className="space-y-3">
<Label htmlFor="csv-pdf-input">Arquivo PDF</Label>
<input
id="csv-pdf-input"
type="file"
accept=".pdf,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
<label
htmlFor="csv-pdf-input"
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors hover:border-primary/50"
>
<Upload className="h-7 w-7 text-primary" />
<div>
<p className="text-sm font-medium">
{selectedFile ? selectedFile.name : "Clique para anexar um PDF"}
</p>
<p className="text-xs text-muted-foreground">
{selectedFile ? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB` : "Somente .pdf"}
</p>
</div>
</label>
</div>
) : (
<div className="space-y-3">
<Label htmlFor="csv-text-input">Conteudo para converter</Label>
<Textarea
id="csv-text-input"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="Cole aqui os dados em texto que serao convertidos para CSV..."
rows={10}
className="resize-y min-h-[220px]"
/>
</div>
)}
<div className="sticky bottom-0 z-10 -mx-6 rounded-b-lg border-t bg-card/95 px-6 py-4 backdrop-blur">
{isSubmitting ? (
<div className="mb-3 flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processando arquivo e gerando CSV...
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{sendBlockReason ?? "Tudo certo. Pronto para enviar."}
</p>
<InteractiveHoverButton
type="button"
onClick={submit}
disabled={isSubmitting || Boolean(sendBlockReason)}
className="min-w-44 disabled:opacity-50 disabled:pointer-events-none"
>
{isSubmitting ? "Enviando..." : "Enviar para API"}
</InteractiveHoverButton>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="w-5 h-5" />
Resposta da API
</CardTitle>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={copyResponse}>
<Copy className="w-4 h-4 mr-2" />
Copiar
</Button>
<Button type="button" variant="outline" size="sm" onClick={downloadCsv} disabled={!responseContent}>
<Download className="w-4 h-4 mr-2" />
Baixar CSV
</Button>
</div>
</div>
{lastSentAt ? (
<p className="text-xs text-muted-foreground">
Ultimo envio: {lastSentAt.toLocaleTimeString("pt-BR")} ({lastSentMode === "pdf" ? "PDF" : "Texto"})
</p>
) : null}
</CardHeader>
<CardContent>
<Textarea
value={responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "Nenhuma resposta ainda.")}
readOnly
className="min-h-[62vh] font-mono text-xs leading-relaxed resize-y"
/>
</CardContent>
</Card>
</div>
</div>
<AlertDialog open={isClearHeadersOpen} onOpenChange={setIsClearHeadersOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Limpar todos os cabecalhos?</AlertDialogTitle>
<AlertDialogDescription>
Essa acao remove todos os cabecalhos adicionados nesta tela.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setHeaders([]);
setIsClearHeadersOpen(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Limpar tudo
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "react-router-dom";
import { Building2, Loader2, MapPinOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import {
getEstabelecimentoCodigoFromTransfer,
lookupUnidadeByEstabelecimento,
} from "@/services/fechamento/unidadeContext";
type GatePhase =
| "loading"
| "ready"
| "sem_codigo_transfer"
| "unidade_nao_cadastrada"
| "usuario_outra_unidade";
type UnidadeGateProps = {
children: ReactNode;
};
function isConfiguracoesPath(pathname: string): boolean {
return pathname.includes("configuracoes");
}
export function UnidadeGate({ children }: UnidadeGateProps) {
const { me, papel } = useAuthAccess();
const location = useLocation();
const [phase, setPhase] = useState<GatePhase>("loading");
const [codigoTransfer, setCodigoTransfer] = useState("");
useEffect(() => {
let mounted = true;
const run = async () => {
const codigo = getEstabelecimentoCodigoFromTransfer();
if (!mounted) return;
setCodigoTransfer(codigo);
if (!codigo) {
setPhase("sem_codigo_transfer");
return;
}
try {
const row = await lookupUnidadeByEstabelecimento(codigo);
if (!mounted) return;
if (!row) {
setPhase("unidade_nao_cadastrada");
return;
}
if (papel !== "admin" && papel !== "supervisor" && me?.unidadeId && me.unidadeId !== row.id) {
setPhase("usuario_outra_unidade");
return;
}
setPhase("ready");
} catch {
if (!mounted) return;
setPhase("unidade_nao_cadastrada");
}
};
void run();
return () => {
mounted = false;
};
}, [me?.unidadeId, location.pathname, papel]);
const isAdmin = papel === "admin";
const isStaff = papel === "admin" || papel === "supervisor";
const onConfiguracoes = isConfiguracoesPath(location.pathname);
if (phase === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Verificando unidade do estabelecimento...</p>
</div>
</div>
);
}
if (phase === "ready") {
return <>{children}</>;
}
if (phase === "unidade_nao_cadastrada" && isStaff && onConfiguracoes) {
return <>{children}</>;
}
if (phase === "sem_codigo_transfer") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<MapPinOff className="h-10 w-10 text-muted-foreground" />
</div>
<CardTitle>Estabelecimento não identificado</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
HGTX Intelligence Score a partir do Commander com o estabelecimento carregado no transfer.
</p>
</CardContent>
</Card>
</div>
);
}
if (phase === "unidade_nao_cadastrada") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<Building2 className="h-10 w-10 text-primary" />
</div>
<CardTitle>Unidade ainda não cadastrada</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
Não existe unidade no Commander para o estabelecimento{" "}
<span className="font-mono text-foreground">{codigoTransfer || "—"}</span>.
</p>
{isAdmin ? (
<>
<p className="text-sm text-muted-foreground">
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
</p>
<Button asChild>
<Link to="/intelligence-score/configuracoes#unidade">Ir para Configurações</Link>
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">
Peça a um administrador para cadastrar a unidade deste estabelecimento no Commander. Informe o
código: <span className="font-mono text-foreground">{codigoTransfer}</span>
</p>
)}
</CardContent>
</Card>
</div>
);
}
if (phase === "usuario_outra_unidade") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle>Estabelecimento diferente do seu cadastro</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
Você está vinculado a outra unidade no Commander do que o estabelecimento aberto neste contexto
(código <span className="font-mono text-foreground">{codigoTransfer}</span>).
</p>
<p className="text-sm">
Abra o módulo com o estabelecimento correspondente ao seu usuário ou solicite ajuste ao
administrador.
</p>
</CardContent>
</Card>
</div>
);
}
return <>{children}</>;
}
+343 -66
View File
@@ -1,8 +1,8 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader"; import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Download, Trash2, Sparkles, Clock, Search } from "lucide-react"; import { Download, Trash2, Sparkles, Clock, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -13,82 +13,226 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
interface GeneratedImage { import {
id: string; imageGenerationService,
url: string; IMAGE_SIZE_OPTIONS,
prompt: string; ImageSize,
size: string; ImageRecord
timestamp: string; } from "@/services/imageGeneration";
} import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
export const ImageView = () => { export const ImageView = () => {
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [selectedSize, setSelectedSize] = useState("1024x1024"); const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [images, setImages] = useState<GeneratedImage[]>([ const [images, setImages] = useState<ImageRecord[]>([]);
{ const [lastGeneratedImage, setLastGeneratedImage] = useState<ImageRecord | null>(null);
id: "1", const [isLoadingImages, setIsLoadingImages] = useState(false);
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop", const [currentPage, setCurrentPage] = useState(1);
prompt: "Paisagem futurista com cidades voadoras", const [perPage, setPerPage] = useState(10);
size: "1024x1024", const { toast } = useToast();
timestamp: "Há 2 horas",
}, // Carrega imagens do banco de dados ao montar o componente
{ const loadImages = async (page: number = currentPage, limit: number = perPage) => {
id: "2", setIsLoadingImages(true);
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop", try {
prompt: "Robô humanoide em estilo cyberpunk", const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
size: "1024x1536",
timestamp: "Há 5 horas", // Garante que sempre seja um array
}, if (Array.isArray(fetchedImages)) {
]); setImages(fetchedImages);
} else {
console.warn('Resposta da API não é um array:', fetchedImages);
setImages([]);
}
} catch (error: any) {
console.error('Erro ao carregar imagens:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de imagens.",
variant: "destructive",
});
setImages([]);
} finally {
setIsLoadingImages(false);
}
};
// Carrega imagens ao montar e quando a paginação mudar
useEffect(() => {
loadImages();
}, [currentPage, perPage]);
const handleGenerate = async () => {
// Valida a descrição antes de enviar
const validation = imageGenerationService.validateDescription(prompt);
if (!validation.valid) {
toast({
title: "Descrição inválida",
description: validation.error,
variant: "destructive",
});
return;
}
const handleGenerate = () => {
setIsGenerating(true); setIsGenerating(true);
// Simulate image generation
setTimeout(() => { try {
const newImage: GeneratedImage = { // Chama o serviço de geração de imagem
id: Date.now().toString(), const response = await imageGenerationService.generateImage({
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop", description: prompt,
prompt: prompt,
size: selectedSize, size: selectedSize,
timestamp: "Agora", });
// Verifica se a geração foi bem-sucedida
if (response.success && response.image_url && response.image_generation_id) {
toast({
title: "Imagem gerada com sucesso",
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
});
// Cria objeto da imagem recém-gerada para exibição imediata
const newGeneratedImage: ImageRecord = {
id: response.image_generation_id,
user_email: '', // Será preenchido pelo backend
estabelecimento_id: 0, // Será preenchido pelo backend
description: response.message, // Descrição original
model: 'dall-e-3', // Modelo padrão
image_url: response.image_url,
size: selectedSize,
cost_usd: '0',
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
created_at: new Date().toISOString(),
}; };
setImages([newImage, ...images]);
setIsGenerating(false); // Salva a última imagem gerada para exibição
setLastGeneratedImage(newGeneratedImage);
// Limpa o campo de descrição após sucesso
setPrompt(""); setPrompt("");
}, 3000);
// Recarrega a lista de imagens (sem aguardar para não bloquear a UI)
loadImages(1, perPage);
setCurrentPage(1);
} else {
// Se success for false ou faltarem dados, trata como erro
throw {
message: response.message || 'Erro ao gerar imagem',
code: response.code,
};
}
} catch (error: any) {
console.error('Erro na geração de imagem:', error);
// Exibe a mensagem de erro exata retornada pela API
const errorMessage = error.message || "Não foi possível gerar a imagem. Tente novamente.";
const errorCode = error.code ? ` (${error.code})` : '';
toast({
title: "Erro na geração",
description: errorMessage + errorCode,
variant: "destructive",
});
} finally {
setIsGenerating(false);
}
}; };
const handleDelete = (id: string) => { const handleDelete = async (id: string) => {
setImages(images.filter((img) => img.id !== id)); try {
const result = await imageGenerationService.deleteImage(id);
if (result.success) {
toast({
title: "Imagem removida",
description: "A imagem foi removida com sucesso.",
});
// Se a imagem deletada for a última gerada, limpa o preview
if (lastGeneratedImage && lastGeneratedImage.id === id) {
setLastGeneratedImage(null);
}
// Recarrega a lista de imagens
await loadImages();
} else {
throw new Error(result.message || 'Erro ao deletar imagem');
}
} catch (error: any) {
console.error('Erro ao deletar imagem:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a imagem.",
variant: "destructive",
});
}
}; };
const filteredImages = images.filter((img) => const handleDownload = async (image: ImageRecord) => {
img.prompt.toLowerCase().includes(searchQuery.toLowerCase()) try {
await imageGenerationService.downloadImage(
image.image_url,
`${image.description.substring(0, 30)}_${image.size}.png`
); );
toast({
title: "Download iniciado",
description: "A imagem está sendo baixada.",
});
} catch (error) {
toast({
title: "Erro no download",
description: "Não foi possível baixar a imagem.",
variant: "destructive",
});
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1); // Reset para primeira página ao mudar itens por página
};
const filteredImages = Array.isArray(images)
? images.filter((img) =>
img.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: [];
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader /> <ChatHeader />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Main Content */} {/* Main Content */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="max-w-5xl mx-auto p-6"> <div className="max-w-5xl mx-auto p-3 md:p-6">
<Tabs defaultValue="generate" className="space-y-6"> <Tabs defaultValue="generate" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect"> <TabsList className="grid w-full grid-cols-2 glass-effect">
<TabsTrigger value="generate">Gerar Imagem</TabsTrigger> <TabsTrigger value="generate" className="text-xs md:text-sm">Gerar Imagem</TabsTrigger>
<TabsTrigger value="history"> <TabsTrigger value="history" className="text-xs md:text-sm">
<Clock className="w-4 h-4 mr-2" /> <Clock className="w-3 h-3 md:w-4 md:h-4 mr-1 md:mr-2" />
Histórico <span className="hidden md:inline">Histórico</span>
<span className="md:hidden">Hist.</span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Generate Tab */} {/* Generate Tab */}
<TabsContent value="generate" className="space-y-6"> <TabsContent value="generate" className="space-y-4 md:space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4"> <div className="glass-effect rounded-xl p-4 md:p-6 space-y-3 md:space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">
Descreva a imagem Descreva a imagem
@@ -101,19 +245,19 @@ export const ImageView = () => {
/> />
</div> </div>
<div className="flex gap-4 items-end"> <div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-end">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<label className="text-sm font-medium text-foreground"> <label className="text-sm font-medium text-foreground">
Tamanho Tamanho
</label> </label>
<Select value={selectedSize} onValueChange={setSelectedSize}> <Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50"> <SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem> <SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem> <SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem> <SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -149,12 +293,13 @@ export const ImageView = () => {
)} )}
{/* Recent Images Preview */} {/* Recent Images Preview */}
{!isGenerating && images.length > 0 && ( {!isGenerating && lastGeneratedImage && (
<div> <div>
<h3 className="text-lg font-semibold mb-4">Última Geração</h3> <h3 className="text-lg font-semibold mb-4">Última Geração</h3>
<ImageCard <ImageCard
image={images[0]} image={lastGeneratedImage}
onDelete={handleDelete} onDelete={handleDelete}
onDownload={handleDownload}
/> />
</div> </div>
)} )}
@@ -162,8 +307,9 @@ export const ImageView = () => {
{/* History Tab */} {/* History Tab */}
<TabsContent value="history" className="space-y-4"> <TabsContent value="history" className="space-y-4">
{/* Search */} {/* Search and Filters */}
<div className="relative"> <div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
value={searchQuery} value={searchQuery}
@@ -172,18 +318,82 @@ export const ImageView = () => {
className="pl-9" className="pl-9"
/> />
</div> </div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingImages ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando imagens...</p>
</div>
) : (
<>
{/* Images Grid */} {/* Images Grid */}
{filteredImages.length > 0 ? ( {filteredImages.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredImages.map((image) => ( {filteredImages.map((image) => (
<ImageCard <ImageCard
key={image.id} key={image.id}
image={image} image={image}
onDelete={handleDelete} onDelete={handleDelete}
onDownload={handleDownload}
/> />
))} ))}
</div> </div>
{/* Pagination */}
{!searchQuery && images.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={images.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
) : ( ) : (
<div className="glass-effect rounded-xl p-12 text-center"> <div className="glass-effect rounded-xl p-12 text-center">
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" /> <Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
@@ -192,6 +402,8 @@ export const ImageView = () => {
</p> </p>
</div> </div>
)} )}
</>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -202,31 +414,96 @@ export const ImageView = () => {
}; };
interface ImageCardProps { interface ImageCardProps {
image: GeneratedImage; image: ImageRecord;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onDownload: (image: ImageRecord) => void;
} }
const ImageCard = ({ image, onDelete }: ImageCardProps) => { const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Calcula tempo relativo
const getRelativeTime = (timestamp: string) => {
const now = new Date();
const date = new Date(timestamp);
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Agora';
if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
if (hours < 24) return `${hours} ${hours === 1 ? 'hora' : 'horas'}`;
return `${days} ${days === 1 ? 'dia' : 'dias'}`;
};
const handleImageError = () => {
console.warn('Erro CORS ao carregar imagem:', image.image_url);
setImageError(true);
setImageLoading(false);
};
const handleImageLoad = () => {
console.log('Imagem carregada com sucesso:', image.image_url);
setImageLoading(false);
setImageError(false);
};
return ( return (
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in"> <div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
<div className="relative aspect-square"> <div className="relative aspect-square">
{imageLoading && !imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-muted">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted text-muted-foreground p-4">
<Sparkles className="w-12 h-12 mb-2 opacity-50" />
<p className="text-sm text-center font-medium mb-1">Erro ao carregar</p>
<p className="text-xs text-center mb-2">A imagem foi gerada, mas não pode ser exibida aqui</p>
<div className="flex flex-col gap-2 w-full">
<a
href={image.image_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Abrir imagem em nova aba
</a>
<button
onClick={() => navigator.clipboard.writeText(image.image_url)}
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
>
Copiar URL
</button>
</div>
</div>
) : (
<img <img
src={image.url} src={image.image_url}
alt={image.prompt} alt={image.description}
className="w-full h-full object-cover" className="w-full h-full object-cover"
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
referrerPolicy="no-referrer"
/> />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2"> <div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<p className="text-sm text-white line-clamp-2">{image.prompt}</p> <p className="text-sm text-white line-clamp-2">{image.description}</p>
<div className="flex items-center justify-between text-xs text-white/70"> <div className="flex items-center justify-between text-xs text-white/70">
<span>{image.size}</span> <span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
<span>{image.timestamp}</span> <span>{getRelativeTime(image.created_at)}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
className="flex-1 gap-1" className="flex-1 gap-1"
onClick={() => onDownload(image)}
> >
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
Baixar Baixar
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
import { areasService, type AreaItem } from "@/services/areas";
import { promptsService, type PromptItem } from "@/services/promptsApi";
import { parecerService } from "@/services/parecerApi";
import { playSuccessSound } from "@/utils/sound";
import { toast } from "sonner";
export function ParecerJuridicoFormView() {
const navigate = useNavigate();
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areasLoading, setAreasLoading] = useState(true);
const [areasError, setAreasError] = useState(false);
const [tituloParecer, setTituloParecer] = useState("");
const [areaId, setAreaId] = useState("");
const [promptId, setPromptId] = useState("");
const [conteudoPrompt, setConteudoPrompt] = useState("");
const [instrucao, setInstrucao] = useState("");
const [anexo, setAnexo] = useState<File | null>(null);
const anexoInputRef = useRef<HTMLInputElement>(null);
const [promptsDaArea, setPromptsDaArea] = useState<PromptItem[]>([]);
const [loadingPrompts, setLoadingPrompts] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
setAreasLoading(true);
setAreasError(false);
areasService.listarTotal()
.then((data) => setAreaItems(data))
.catch(() => setAreasError(true))
.finally(() => setAreasLoading(false));
}, []);
const ANEXO_ACCEPT = ".pdf,.txt";
const handleAnexoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
setAnexo(null);
return;
}
const ext = file.name.split(".").pop()?.toLowerCase();
const allowed = ["pdf", "txt"];
if (!ext || !allowed.includes(ext)) {
toast.error("Formato não permitido. Use PDF ou TXT.");
e.target.value = "";
return;
}
setAnexo(file);
};
const removerAnexo = () => {
setAnexo(null);
if (anexoInputRef.current) anexoInputRef.current.value = "";
};
useEffect(() => {
if (!areaId.trim()) {
setPromptsDaArea([]);
setPromptId("");
setConteudoPrompt("");
return;
}
setPromptId("");
setConteudoPrompt("");
setLoadingPrompts(true);
promptsService.listarPorArea(areaId)
.then((res) => setPromptsDaArea(res.data ?? []))
.catch(() => {
toast.error("Erro ao carregar prompts da área.");
setPromptsDaArea([]);
})
.finally(() => setLoadingPrompts(false));
}, [areaId]);
useEffect(() => {
if (!promptId.trim() || promptsDaArea.length === 0) return;
const prompt = promptsDaArea.find((p) => p.id === promptId);
if (prompt) setConteudoPrompt(prompt.conteudo ?? "");
}, [promptId, promptsDaArea]);
const canSubmit =
!isGenerating &&
tituloParecer.trim().length > 0 &&
areaId.trim().length > 0 &&
conteudoPrompt.trim().length > 0;
const handleGerarParecer = () => {
const titulo = tituloParecer.trim();
const area = areaId.trim();
const prompt = conteudoPrompt.trim();
const instr = instrucao.trim();
if (!titulo || !area || !prompt) {
toast.error("Preencha os campos obrigatórios antes de gerar.");
return;
}
setIsGenerating(true);
parecerService.gerar({
titulo,
area_id: area,
prompt_id: promptId.trim() || undefined,
prompt,
instrucao: instr || undefined,
anexo: anexo ?? undefined,
})
.then((data) => {
setIsGenerating(false);
playSuccessSound();
navigate(`/commander/parecer-juridico/${data.id}`, {
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
});
})
.catch((err) => {
setIsGenerating(false);
toast.error(err?.message ?? "Erro ao gerar parecer.");
});
};
const descricaoPromptSelecionado = promptId ? promptsDaArea.find((p) => p.id === promptId)?.descricao?.trim() : "";
return (
<>
{isGenerating && (
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
)}
<div className="flex flex-col h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
<div className="shrink-0 p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
<ArrowLeft className="w-4 h-4" />
</Button>
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Sparkles className="w-5 h-5 md:w-6 md:h-6" />
Novo Parecer
</h1>
</div>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-y-contain p-3 md:p-6">
<div className="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
<div className="space-y-2">
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
<span>Título do parecer</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Input
id="titulo-parecer"
value={tituloParecer}
onChange={(e) => setTituloParecer(e.target.value)}
placeholder="Ex.: Parecer sobre contrato de prestação de serviços"
className="w-full"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor="area" className="flex flex-wrap items-baseline gap-1">
<span>Área</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Select value={areaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="area" className="w-full">
<SelectValue
placeholder={
areasLoading ? "Carregando áreas..." : areasError ? "Erro ao carregar áreas" : "Selecione a área"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione a área</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
{areasError && (
<p className="text-sm text-destructive">Não foi possível carregar as áreas. Verifique sua conexão e tente novamente.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="prompt">Prompt da base</Label>
<Select
value={promptId || "none"}
onValueChange={(v) => setPromptId(v === "none" ? "" : v)}
disabled={!areaId.trim() || loadingPrompts}
>
<SelectTrigger id="prompt" className="w-full">
<SelectValue
placeholder={
loadingPrompts ? "Carregando..." : promptsDaArea.length === 0 && areaId.trim() ? "Nenhum prompt" : "Selecione o prompt"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione o prompt</SelectItem>
{promptsDaArea.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.titulo}
</SelectItem>
))}
</SelectContent>
</Select>
{areaId.trim() && !loadingPrompts && promptsDaArea.length === 0 && (
<p className="text-sm text-muted-foreground">Não prompts para essa área.</p>
)}
{descricaoPromptSelecionado && (
<p className="text-sm text-muted-foreground mt-1">{descricaoPromptSelecionado}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="conteudo-prompt" className="flex flex-wrap items-baseline gap-1">
<span>Conteúdo do prompt</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Textarea
id="conteudo-prompt"
value={conteudoPrompt}
onChange={(e) => setConteudoPrompt(e.target.value)}
placeholder="Selecione um prompt acima ou edite o conteúdo aqui."
className="min-h-[200px] resize-y w-full"
required
aria-required="true"
/>
</div>
<div className="space-y-2">
<Label htmlFor="instrucao">Instrução</Label>
<Textarea
id="instrucao"
value={instrucao}
onChange={(e) => setInstrucao(e.target.value)}
placeholder="Instruções adicionais para a geração do parecer (opcional)..."
className="min-h-[100px] resize-y w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="anexo">Anexo</Label>
<p className="text-xs text-muted-foreground">Formatos permitidos: PDF, TXT.</p>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2 w-full min-h-10 rounded-md border border-input bg-background px-3 py-2">
<input
ref={anexoInputRef}
id="anexo"
type="file"
accept={ANEXO_ACCEPT}
onChange={handleAnexoChange}
className="sr-only"
/>
<Label
htmlFor="anexo"
className="cursor-pointer shrink-0 rounded-md border-0 bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Escolher arquivo
</Label>
<span className="text-sm text-muted-foreground truncate flex-1 min-w-0">
{anexo ? anexo.name : "Nenhum arquivo escolhido"}
</span>
{anexo && (
<Button type="button" variant="ghost" size="icon" onClick={removerAnexo} className="h-8 w-8 shrink-0" title="Remover anexo">
<X className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
<div className="flex justify-end pt-2">
<InteractiveHoverButton
type="button"
onClick={handleGerarParecer}
disabled={!canSubmit}
className="disabled:opacity-50 disabled:pointer-events-none"
>
Gerar Parecer
</InteractiveHoverButton>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,256 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import {
Bot,
Sparkles,
FileText,
Globe,
BookOpen,
Database,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { AnimatedBeam } from "@/components/ui/animated-beam";
import { cn } from "@/lib/utils";
const GERAR_PARECER_STEPS = [
"Pesquisando fontes jurídicas confiáveis...",
"Analisando documentação e anexos...",
"Consultando jurisprudência e decisões correlatas...",
"Verificando legislação vigente e súmulas...",
"Identificando fundamentação legal aplicável...",
"Estruturando argumentação...",
"Elaborando conclusões...",
"Revisando citações e referências...",
"Consolidando fundamentos e redigindo parecer...",
"Estruturando parecer...",
] as const;
const STEP_COUNT = GERAR_PARECER_STEPS.length;
const STEP_INTERVAL_MS = 1500;
export interface ParecerJuridicoGeneratingScreenProps {
progress?: number;
message?: string;
}
function visibleLineCountFromProgress(progress: number): number {
if (progress <= 0) return 1;
const phase = (progress / 100) * STEP_COUNT;
const stepInCycle = Math.floor(phase) % STEP_COUNT;
return Math.min(stepInCycle + 1, STEP_COUNT);
}
const BeamCircle = forwardRef<
HTMLDivElement,
{ className?: string; children?: React.ReactNode }
>(({ className, children }, ref) => (
<div
ref={ref}
className={cn(
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
className
)}
>
{children}
</div>
));
BeamCircle.displayName = "BeamCircle";
function GoogleIcon({ className }: { className?: string }) {
return (
<svg
className={cn("size-6", className)}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}
export function ParecerJuridicoGeneratingScreen({
progress,
message = "Assim que finalizar, o modal será fechado automaticamente.",
}: ParecerJuridicoGeneratingScreenProps) {
const containerRef = useRef<HTMLDivElement>(null);
const sparklesRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLDivElement>(null);
const globeRef = useRef<HTMLDivElement>(null);
const centerRef = useRef<HTMLDivElement>(null);
const googleRef = useRef<HTMLDivElement>(null);
const bookRef = useRef<HTMLDivElement>(null);
const dbRef = useRef<HTMLDivElement>(null);
const [loopStep, setLoopStep] = useState(0);
useEffect(() => {
if (progress !== undefined) return;
const id = setInterval(() => {
setLoopStep((s) => (s + 1) % STEP_COUNT);
}, STEP_INTERVAL_MS);
return () => clearInterval(id);
}, [progress]);
const visibleCount =
progress !== undefined
? visibleLineCountFromProgress(progress)
: loopStep + 1;
return (
<Dialog open>
<DialogContent
hideClose
overlayClassName="bg-black/50"
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
<div className="flex gap-1.5">
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
</div>
<span className="font-mono text-xs text-muted-foreground">
codex parecer gerando
</span>
</div>
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
<AnimatedStep delay={0} className="text-muted-foreground">
&gt; codex parecer --generate
</AnimatedStep>
{GERAR_PARECER_STEPS.slice(0, visibleCount).map((text, i) => (
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
{i < visibleCount - 1 ? (
<span className="text-green-600 dark:text-green-400">
{text}
</span>
) : (
<TypingAnimation
key={`typing-${visibleCount}`}
startOnView={false}
showCursor
cursorStyle="block"
className="text-green-600 dark:text-green-400"
>
{`${text}`}
</TypingAnimation>
)}
</div>
))}
</Terminal>
<p className="text-center text-sm text-muted-foreground">
{message ?? "A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar."}
</p>
<div className="rounded-xl border border-border bg-white p-6">
<div
ref={containerRef}
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
>
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={sparklesRef}>
<Sparkles className="size-6 text-amber-400" />
</BeamCircle>
<BeamCircle ref={fileRef}>
<FileText className="size-6 text-slate-600" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={globeRef}>
<Globe className="size-6 text-sky-400" />
</BeamCircle>
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
<Bot className="size-8 text-slate-700" />
</BeamCircle>
<BeamCircle ref={googleRef}>
<GoogleIcon className="size-6" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={bookRef}>
<BookOpen className="size-6 text-slate-600" />
</BeamCircle>
<BeamCircle ref={dbRef}>
<Database className="size-6 text-slate-600" />
</BeamCircle>
</div>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={sparklesRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
gradientStartColor="#f59e0b"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={fileRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={globeRef}
toRef={centerRef}
gradientStartColor="#0ea5e9"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={googleRef}
toRef={centerRef}
reverse
gradientStartColor="#64748b"
gradientStopColor="#94a3b8"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={bookRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={dbRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,316 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Sparkles, Edit, Plus, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import { parecerService, type ParecerListItem, type ParecerStatus } from "@/services/parecerApi";
import { toast } from "sonner";
const STATUS_OPTIONS: { value: "" | ParecerStatus; label: string }[] = [
{ value: "", label: "Todos os status" },
{ value: "processando", label: "Processando" },
{ value: "concluido", label: "Concluído" },
{ value: "erro", label: "Erro" },
];
function formatarData(createdAt: string): string {
if (!createdAt) return "—";
try {
const d = new Date(createdAt.replace(" ", "T"));
return d.toLocaleDateString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return createdAt;
}
}
function statusEstilo(status: string): { dot: string; text: string; label: string } {
const s = (status ?? "").toLowerCase();
if (s === "concluido") return { dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", label: "Concluído" };
if (s === "processando") return { dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", label: "Processando" };
if (s === "erro") return { dot: "bg-red-500", text: "text-red-600 dark:text-red-400", label: "Erro" };
return { dot: "bg-muted-foreground/50", text: "text-muted-foreground", label: status || "—" };
}
export const ParecerJuridicoView = () => {
const navigate = useNavigate();
const { areaItems, refreshAreas } = usePrompts();
const [data, setData] = useState<ParecerListItem[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [paginaAtual, setPaginaAtual] = useState(1);
const [perPage, setPerPage] = useState(10);
const [loading, setLoading] = useState(true);
const [filterTitulo, setFilterTitulo] = useState("");
const [filterAreaId, setFilterAreaId] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<"" | ParecerStatus>("");
const hasActiveFilters = filterTitulo.trim() !== "" || filterAreaId !== "" || filterStatus !== "";
const clearFilters = () => {
setFilterTitulo("");
setFilterAreaId("");
setFilterStatus("");
setPaginaAtual(1);
};
const loadList = useCallback(() => {
setLoading(true);
parecerService
.listar({
titulo: filterTitulo.trim() || undefined,
area_id: filterAreaId.trim() || undefined,
status: filterStatus || undefined,
page: paginaAtual,
per_page: perPage,
})
.then((res) => {
setData(res.data ?? []);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(Math.max(1, res.total_paginas ?? 1));
setPaginaAtual(res.pagina_atual ?? 1);
setPerPage(res.per_page ?? 10);
})
.catch((err) => {
toast.error(err?.message ?? "Erro ao carregar pareceres.");
setData([]);
setTotalRegistros(0);
setTotalPaginas(1);
})
.finally(() => setLoading(false));
}, [paginaAtual, perPage, filterTitulo, filterAreaId, filterStatus]);
useEffect(() => {
loadList();
}, [loadList]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const handleItemsPerPageChange = (value: string) => {
setPerPage(Number(value));
setPaginaAtual(1);
};
const handleEditar = (item: ParecerListItem) => {
navigate(`/commander/parecer-juridico/${item.id}`);
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<Bot className="w-5 h-5 md:w-6 md:h-6" />
Parecer Jurídico
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/commander/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo Parecer</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4 flex-wrap">
<Input
placeholder="Buscar por título..."
value={filterTitulo}
onChange={(e) => { setFilterTitulo(e.target.value); setPaginaAtual(1); }}
className="w-full md:max-w-sm text-sm"
/>
<Select value={filterAreaId || "all"} onValueChange={(v) => { setFilterAreaId(v === "all" ? "" : v); setPaginaAtual(1); }}>
<SelectTrigger className="w-full md:w-[180px] text-sm">
<SelectValue placeholder="Todas as áreas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as áreas</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filterStatus || "all"}
onValueChange={(v) => { setFilterStatus((v === "all" ? "" : v) as "" | ParecerStatus); setPaginaAtual(1); }}
>
<SelectTrigger className="w-full md:w-[160px] text-sm">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value || "all"} value={opt.value || "all"}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens por página:</span>
<Select value={perPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{loading ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardContent className="py-10 text-center text-muted-foreground">
Carregando pareceres...
</CardContent>
</Card>
) : data.length === 0 && !hasActiveFilters ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
Nenhum parecer encontrado
</CardTitle>
<CardDescription>
Os pareceres jurídicos gerados aparecerão aqui quando disponíveis.
</CardDescription>
</CardHeader>
<CardContent />
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px] font-semibold text-xs md:text-sm">Data</TableHead>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Status</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Criado por</TableHead>
<TableHead className="text-center font-semibold text-xs md:text-sm w-[80px]">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
Nenhum resultado para os filtros aplicados.
</TableCell>
</TableRow>
) : (
data.map((item) => {
const status = statusEstilo(item.status);
return (
<TableRow key={item.id}>
<TableCell className="text-xs md:text-sm">{formatarData(item.created_at)}</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{item.titulo}</TableCell>
<TableCell className="text-xs md:text-sm">{item.area_nome}</TableCell>
<TableCell className="text-xs md:text-sm">
<span className={`inline-flex items-center gap-2 ${status.text}`}>
<span className={`size-2 rounded-full shrink-0 ${status.dot}`} aria-hidden />
{status.label}
</span>
</TableCell>
<TableCell className="text-xs md:text-sm">{item.criado_por ?? "—"}</TableCell>
<TableCell>
<div className="flex items-center justify-center">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditar(item)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
); })
)}
</TableBody>
</Table>
</div>
{totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {data.length} de {totalRegistros} {totalRegistros === 1 ? "parecer" : "pareceres"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setPaginaAtual((p) => Math.max(1, p - 1))}
disabled={paginaAtual <= 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPaginas, 5) }, (_, i) => {
let page: number;
if (totalPaginas <= 5) page = i + 1;
else if (paginaAtual <= 3) page = i + 1;
else if (paginaAtual >= totalPaginas - 2) page = totalPaginas - 4 + i;
else page = paginaAtual - 2 + i;
return (
<Button
key={page}
variant={paginaAtual === page ? "default" : "outline"}
size="sm"
onClick={() => setPaginaAtual(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPaginaAtual((p) => Math.min(totalPaginas, p + 1))}
disabled={paginaAtual >= totalPaginas}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,92 @@
import { Sparkles } from "lucide-react";
import { useTheme } from "next-themes";
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
import { cn } from "@/lib/utils";
type PromptAssistantButtonProps = {
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
className?: string;
asBadge?: boolean;
};
function useAssistantPalette() {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return {
isDark,
textFrom: isDark ? "hsl(210, 55%, 88%)" : "hsl(210, 100%, 28%)",
textTo: isDark ? "hsl(210, 70%, 72%)" : "hsl(210, 75%, 40%)",
};
}
const shellClass = (opts: { asBadge?: boolean; disabled?: boolean; interactive?: boolean }) =>
cn(
"inline-flex items-center gap-2.5 rounded-full border transition-all duration-300",
"border-primary/20 bg-gradient-to-br from-primary/[0.07] via-card to-accent/30",
"shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_4px_16px_hsl(var(--primary)/0.06)]",
opts.asBadge ? "px-3.5 py-1.5" : "min-h-10 px-4 py-2",
opts.interactive &&
!opts.disabled &&
"hover:border-primary/35 hover:from-primary/[0.1] hover:shadow-[0_2px_8px_hsl(var(--foreground)/0.05),0_8px_24px_hsl(var(--primary)/0.12)] hover:-translate-y-px active:translate-y-0",
opts.disabled && "opacity-60"
);
export function PromptAssistantButton({
onClick,
disabled,
loading,
className,
asBadge = false,
}: PromptAssistantButtonProps) {
const palette = useAssistantPalette();
const label = loading ? (
<span className="text-sm font-medium text-muted-foreground">Gerando em segundo plano...</span>
) : (
<AnimatedGradientText
speed={0.75}
colorFrom={palette.textFrom}
colorTo={palette.textTo}
className="text-sm font-medium tracking-tight"
>
Assistente de criação de prompt
</AnimatedGradientText>
);
const content = (
<>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary",
asBadge ? "h-6 w-6" : "h-7 w-7"
)}
aria-hidden
>
<Sparkles className={asBadge ? "h-3 w-3" : "h-3.5 w-3.5"} />
</span>
{label}
</>
);
if (asBadge) {
return <span className={cn(shellClass({ asBadge: true }), className)}>{content}</span>;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
className={cn(
"rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none",
className
)}
>
<span className={shellClass({ disabled: disabled || loading, interactive: true })}>{content}</span>
</button>
);
}
@@ -0,0 +1,248 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import {
Bot,
Sparkles,
FileText,
Wand2,
SlidersHorizontal,
ListChecks,
MessageSquareText,
} from "lucide-react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { AnimatedBeam } from "@/components/ui/animated-beam";
import { cn } from "@/lib/utils";
const PROMPT_CREATE_STEPS = [
"Interpretando instruções e contexto...",
"Definindo objetivo, público e tom...",
"Organizando estrutura do prompt (seções e regras)...",
"Especificando formato de saída e restrições...",
"Adicionando critérios de qualidade e validações...",
"Incluindo exemplos e casos de borda...",
"Revisando clareza e consistência...",
"Finalizando prompt...",
] as const;
const PROMPT_REFINE_STEPS = [
"Lendo o prompt atual...",
"Identificando ambiguidades e pontos fracos...",
"Removendo redundâncias e ruído...",
"Aprimorando instruções e critérios de sucesso...",
"Ajustando tom, voz e consistência...",
"Fortalecendo formato de saída e validações...",
"Adicionando exemplos e casos de borda...",
"Consolidando melhorias e finalizando...",
] as const;
const STEP_INTERVAL_MS = 1500;
export type PromptGeneratingMode = "create" | "refine";
export interface PromptGeneratingScreenProps {
mode: PromptGeneratingMode;
progress?: number;
message?: string;
}
function visibleLineCount(progress: number, stepCount: number): number {
if (progress <= 0) return 1;
const phase = (progress / 100) * stepCount;
const stepInCycle = Math.floor(phase) % stepCount;
return Math.min(stepInCycle + 1, stepCount);
}
const BeamCircle = forwardRef<
HTMLDivElement,
{ className?: string; children?: React.ReactNode }
>(({ className, children }, ref) => (
<div
ref={ref}
className={cn(
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
className
)}
>
{children}
</div>
));
BeamCircle.displayName = "BeamCircle";
export function PromptGeneratingScreen({
mode,
progress,
message = "Assim que finalizar, o modal será fechado automaticamente.",
}: PromptGeneratingScreenProps) {
const steps = mode === "refine" ? PROMPT_REFINE_STEPS : PROMPT_CREATE_STEPS;
const stepCount = steps.length;
const containerRef = useRef<HTMLDivElement>(null);
const sparklesRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLDivElement>(null);
const slidersRef = useRef<HTMLDivElement>(null);
const centerRef = useRef<HTMLDivElement>(null);
const wandRef = useRef<HTMLDivElement>(null);
const checklistRef = useRef<HTMLDivElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const [loopStep, setLoopStep] = useState(0);
useEffect(() => {
if (progress !== undefined) return;
const id = setInterval(() => {
setLoopStep((s) => (s + 1) % stepCount);
}, STEP_INTERVAL_MS);
return () => clearInterval(id);
}, [progress, stepCount]);
const visibleCount =
progress !== undefined
? visibleLineCount(progress, stepCount)
: loopStep + 1;
return (
<Dialog open>
<DialogContent
hideClose
overlayClassName="bg-black/50"
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
<div className="flex gap-1.5">
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
</div>
<span className="font-mono text-xs text-muted-foreground">
codex prompt {mode === "refine" ? "melhorando" : "gerando"}
</span>
</div>
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
<AnimatedStep delay={0} className="text-muted-foreground">
{`> codex prompt --${mode === "refine" ? "refine" : "create"}`}
</AnimatedStep>
{steps.slice(0, visibleCount).map((text, i) => (
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
{i < visibleCount - 1 ? (
<span className="text-sky-600 dark:text-sky-400">
{text}
</span>
) : (
<TypingAnimation
key={`typing-${visibleCount}`}
startOnView={false}
showCursor
cursorStyle="block"
className="text-sky-600 dark:text-sky-400"
>
{`${text}`}
</TypingAnimation>
)}
</div>
))}
</Terminal>
{message && (
<p className="text-center text-sm text-muted-foreground">
{message}
</p>
)}
<div className="rounded-xl border border-border bg-white p-6">
<div
ref={containerRef}
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
>
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={sparklesRef}>
<Sparkles className="size-6 text-sky-500" />
</BeamCircle>
<BeamCircle ref={fileRef}>
<FileText className="size-6 text-slate-600" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={slidersRef}>
<SlidersHorizontal className="size-6 text-indigo-500" />
</BeamCircle>
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
<Bot className="size-8 text-slate-700" />
</BeamCircle>
<BeamCircle ref={wandRef}>
<Wand2 className="size-6 text-amber-500" />
</BeamCircle>
</div>
<div className="flex flex-row items-center justify-between">
<BeamCircle ref={checklistRef}>
<ListChecks className="size-6 text-slate-600" />
</BeamCircle>
<BeamCircle ref={chatRef}>
<MessageSquareText className="size-6 text-slate-600" />
</BeamCircle>
</div>
</div>
<AnimatedBeam
containerRef={containerRef}
fromRef={sparklesRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
gradientStartColor="#0ea5e9"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={fileRef}
toRef={centerRef}
curvature={-75}
endYOffset={-10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={slidersRef}
toRef={centerRef}
gradientStartColor="#6366f1"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={wandRef}
toRef={centerRef}
reverse
gradientStartColor="#f59e0b"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={checklistRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
<AnimatedBeam
containerRef={containerRef}
fromRef={chatRef}
toRef={centerRef}
curvature={75}
endYOffset={10}
reverse
gradientStartColor="#94a3b8"
gradientStopColor="#64748b"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+393
View File
@@ -0,0 +1,393 @@
import React, { useState, useEffect, useRef, forwardRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, ArrowRight, Sparkles, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
import { cn } from "@/lib/utils";
import { usePrompts } from "@/contexts/PromptsContext";
import { areasService, type AreaItem } from "@/services/areas";
import { PromptGeneratingScreen } from "@/components/prompts/PromptGeneratingScreen";
import { assistentePromptsService } from "@/services/assistentePrompts";
import { promptsService } from "@/services/promptsApi";
import { toast } from "sonner";
import { playSuccessSound } from "@/utils/sound";
export function PromptsFormView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { prompts, setPrompts } = usePrompts();
const isEdit = Boolean(id);
const prompt = id ? prompts.find((p) => p.id === id) : null;
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areasLoading, setAreasLoading] = useState(true);
const [titulo, setTitulo] = useState("");
const [descricao, setDescricao] = useState("");
const [areaId, setAreaId] = useState("");
const [conteudo, setConteudo] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
const [assistantMode, setAssistantMode] = useState<"create" | "refine">("create");
const [assistantInstructions, setAssistantInstructions] = useState("");
const [isAssistantLoading, setIsAssistantLoading] = useState(false);
const [isAssistantWorkingInBackground, setIsAssistantWorkingInBackground] = useState(false);
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
const confettiRef = useRef<ConfettiRef>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const hasContent = conteudo.trim().length > 0;
const effectiveAreaId = areaId && areaItems.some((a) => a.id === areaId) ? areaId : (areaItems[0]?.id ?? "");
const effectiveAreaName = areaItems.find((a) => a.id === effectiveAreaId)?.nome ?? "";
useEffect(() => {
setAreasLoading(true);
areasService.listarTotal()
.then((data) => setAreaItems(data))
.catch(() => setAreaItems([]))
.finally(() => setAreasLoading(false));
}, []);
useEffect(() => {
if (prompt) {
setTitulo(prompt.titulo);
setDescricao(prompt.descricao ?? "");
setAreaId(prompt.area_id ?? "");
setConteudo(prompt.conteudo ?? "");
} else if (!id) {
setTitulo("");
setDescricao("");
setAreaId("");
setConteudo("");
}
}, [prompt, id]);
useEffect(() => {
if (!id && areaItems.length > 0 && !areaId) {
setAreaId(areaItems[0].id);
}
}, [id, areaItems, areaId]);
useEffect(() => {
if (id && !prompt && prompts.length > 0) {
navigate("/commander/prompts", { replace: true });
}
}, [id, prompt, prompts.length, navigate]);
useEffect(() => {
if (!conteudo.trim() && assistantMode === "refine") {
setAssistantMode("create");
}
}, [conteudo, assistantMode]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const handleSave = async () => {
const finalTitulo = titulo.trim();
const finalAreaId = effectiveAreaId;
const finalConteudo = conteudo.trim();
if (!finalTitulo || !finalAreaId) {
toast.error("Preencha o título e a área.");
return;
}
if (!finalConteudo) {
toast.error("Preencha o conteúdo do prompt.");
return;
}
const finalAreaName = areaItems.find((a) => a.id === finalAreaId)?.nome ?? "";
const finalDescricao = descricao.trim();
setIsSaving(true);
try {
if (isEdit && prompt) {
const res = await promptsService.editar(prompt.id, {
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) =>
prev.map((p) =>
p.id === prompt.id
? { ...p, id: res.id, titulo: res.titulo, area: finalAreaName, area_id: res.area_id, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }
: p
)
);
toast.success("Prompt atualizado.");
} else {
const res = await promptsService.criar({
titulo: finalTitulo,
descricao: finalDescricao,
area_id: finalAreaId,
conteudo: finalConteudo,
});
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
toast.success("Prompt criado.");
}
navigate("/commander/prompts");
} catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
toast.error(message);
} finally {
setIsSaving(false);
}
};
const handleAssistantSubmit = () => {
if (!assistantInstructions.trim()) {
toast.error("Preencha as instruções.");
return;
}
if (assistantMode === "refine" && !conteudo.trim()) {
toast.error("Não há conteúdo para melhorar.");
return;
}
setIsAssistantOpen(false);
setShowGeneratingModal(true);
setIsAssistantWorkingInBackground(true);
setIsAssistantLoading(true);
const ac = new AbortController();
abortControllerRef.current = ac;
const signal = ac.signal;
const onSuccess = (text: string) => {
setConteudo(text);
confettiRef.current?.fire({});
playSuccessSound();
const msg =
assistantMode === "create" ? "Modelo inicial gerado!" : "Conteúdo atualizado!";
toast.success(msg, {
className: "bg-green-600 border-green-700 text-white",
});
};
const onFinish = () => {
setShowGeneratingModal(false);
setIsAssistantLoading(false);
setIsAssistantWorkingInBackground(false);
abortControllerRef.current = null;
};
const onError = (e: unknown) => {
const isAborted =
(e && typeof e === "object" && "code" in e && (e as { code: string }).code === "ERR_CANCELED") ||
(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
if (!isAborted) {
const message =
e && typeof e === "object" && "message" in e
? (e as { message: string }).message
: "Erro ao processar.";
toast.error(message);
}
};
if (assistantMode === "create") {
assistentePromptsService.gerar(assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
} else {
assistentePromptsService.refinar(conteudo, assistantInstructions, signal)
.then(onSuccess)
.catch(onError)
.finally(onFinish);
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0 relative">
<Confetti
ref={confettiRef}
manualstart
className="absolute inset-0 size-full pointer-events-none z-50"
/>
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
<div className="flex flex-col gap-3">
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px] space-y-2">
<Label htmlFor="form-titulo">Título</Label>
<Input
id="form-titulo"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
placeholder="Ex: Prompt para análise de documentos"
/>
</div>
<div className="w-full sm:w-[220px] space-y-2">
<Label htmlFor="form-area">Área</Label>
<Select value={effectiveAreaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
<SelectTrigger id="form-area">
<SelectValue placeholder={areasLoading ? "Carregando áreas..." : "Selecione a área"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Selecione a área</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="form-descricao">Descrição <span className="text-muted-foreground font-normal">(opcional)</span></Label>
<Input
id="form-descricao"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Ex: Breve descrição do prompt"
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 p-3 md:p-6 gap-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<Label htmlFor="form-conteudo">Conteúdo do Prompt</Label>
<div className="flex items-center gap-2">
{isAssistantWorkingInBackground && (
<div
className="w-2 h-2 rounded-full bg-primary animate-pulse-glow shrink-0"
title="Gerando em andamento"
/>
)}
<PromptAssistantButton
onClick={() => setIsAssistantOpen(true)}
disabled={isAssistantWorkingInBackground}
loading={isAssistantWorkingInBackground}
/>
</div>
</div>
<Textarea
id="form-conteudo"
value={conteudo}
onChange={(e) => setConteudo(e.target.value)}
placeholder="Digite o conteúdo do prompt..."
className="flex-1 min-h-[200px] resize-none p-3"
/>
<div className="flex justify-end pt-2">
<InteractiveHoverButton
type="button"
onClick={handleSave}
disabled={!titulo.trim() || !effectiveAreaId || !conteudo.trim() || isSaving}
className="disabled:opacity-50 disabled:pointer-events-none"
>
{isSaving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar prompt"}
</InteractiveHoverButton>
</div>
</div>
{showGeneratingModal && (
<PromptGeneratingScreen
mode={assistantMode}
message="Está em processamento. Aguarde que em breve o prompt ficará pronto. O modal será fechado assim que finalizar."
/>
)}
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col gap-6 overflow-hidden rounded-xl border border-border/80 bg-card p-8 shadow-modal">
<DialogHeader className="space-y-4">
<div className="flex justify-center">
<PromptAssistantButton asBadge />
</div>
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
<DialogDescription className="text-base text-muted-foreground">
Escolha criar um novo prompt do zero ou melhorar o conteúdo atual. Preencha as instruções e o assistente gerará o texto.
</DialogDescription>
</DialogHeader>
<div className="space-y-8 overflow-y-auto flex-1 min-h-0 px-1 pt-1 pb-2 pr-3 relative z-10">
<div className="space-y-4">
<Label className="text-base font-medium">O que deseja fazer?</Label>
<RadioGroup
value={assistantMode}
onValueChange={(v) => setAssistantMode(v as "create" | "refine")}
className="grid gap-4"
>
<div className="flex items-center space-x-3">
<RadioGroupItem value="create" id="assistant-create" />
<Label htmlFor="assistant-create" className="font-normal cursor-pointer">
Criar novo
</Label>
</div>
<div className="flex items-center space-x-3">
<RadioGroupItem
value="refine"
id="assistant-refine"
disabled={!hasContent}
/>
<Label
htmlFor="assistant-refine"
className={`font-normal ${!hasContent ? "cursor-not-allowed text-muted-foreground" : "cursor-pointer"}`}
>
Melhorar atual
{!hasContent && " (preencha o conteúdo do prompt antes)"}
</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-4">
<Label htmlFor="assistant-instructions" className="text-base font-medium">
Instruções
</Label>
<Textarea
id="assistant-instructions"
value={assistantInstructions}
onChange={(e) => setAssistantInstructions(e.target.value)}
placeholder={
assistantMode === "create"
? "Ex: Um prompt que resuma reuniões em tópicos e ações..."
: "Ex: Tornar mais conciso, adicionar seção de exemplos..."
}
rows={8}
className="resize-none text-base py-4 px-4 min-h-[180px]"
/>
</div>
</div>
<DialogFooter className="gap-3 pt-4 relative z-10">
<Button variant="outline" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
Fechar
</Button>
<Button
onClick={handleAssistantSubmit}
disabled={
!assistantInstructions.trim() ||
isAssistantLoading ||
(assistantMode === "refine" && !hasContent)
}
className="gap-2"
>
<Sparkles className="w-4 h-4 shrink-0" />
{assistantMode === "create" ? "Gerar" : "Melhorar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+459
View File
@@ -0,0 +1,459 @@
import { useState, useMemo, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Plus, Eye, Edit, Copy, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import type { Prompt } from "@/contexts/PromptsContext";
import { promptsService, type PromptItem } from "@/services/promptsApi";
import { toast } from "sonner";
function formatarDataCriacao(iso: string | undefined): string {
if (!iso?.trim()) return "—";
try {
return new Date(iso.replace(" ", "T")).toLocaleDateString("pt-BR");
} catch {
return iso;
}
}
function apiItemToPrompt(item: PromptItem): Prompt {
return {
id: item.id,
titulo: item.titulo,
area: item.area_nome,
area_id: item.area_id,
descricao: item.descricao ?? undefined,
conteudo: item.conteudo ?? "",
created_at: item.created_at,
};
}
export const PromptsView = () => {
const navigate = useNavigate();
const { prompts, setPrompts, areaItems, refreshAreas } = usePrompts();
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [promptToDelete, setPromptToDelete] = useState<Prompt | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [filterNome, setFilterNome] = useState("");
const [filterAreaId, setFilterAreaId] = useState<string>("");
const [promptsList, setPromptsList] = useState<Prompt[]>([]);
const [totalRegistros, setTotalRegistros] = useState(0);
const [totalPaginas, setTotalPaginas] = useState(1);
const [loadingList, setLoadingList] = useState(true);
const [isDeletingPrompt, setIsDeletingPrompt] = useState(false);
const totalPages = Math.max(1, totalPaginas);
const selectedAreaItem = useMemo(
() => (selectedPrompt ? areaItems.find((a) => a.nome === selectedPrompt.area || a.id === selectedPrompt.area_id) ?? null : null),
[selectedPrompt, areaItems]
);
useEffect(() => {
let cancelled = false;
setLoadingList(true);
promptsService.listar({
titulo: filterNome.trim() || undefined,
area_id: filterAreaId.trim() || undefined,
page: currentPage,
per_page: itemsPerPage,
})
.then((res) => {
if (!cancelled) {
const list = (res.data ?? []).map(apiItemToPrompt);
setPromptsList(list);
setTotalRegistros(res.total_registros ?? 0);
setTotalPaginas(res.total_paginas ?? 1);
setPrompts((prev) => {
const byId = new Map(prev.map((p) => [p.id, p]));
list.forEach((p) => byId.set(p.id, p));
return Array.from(byId.values());
});
}
})
.catch((e: unknown) => {
if (!cancelled) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar prompts.";
toast.error(msg);
setPromptsList([]);
setTotalRegistros(0);
setTotalPaginas(1);
}
})
.finally(() => {
if (!cancelled) setLoadingList(false);
});
return () => { cancelled = true; };
}, [filterNome, filterAreaId, currentPage, itemsPerPage]);
useEffect(() => {
setCurrentPage(1);
}, [filterNome, filterAreaId]);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
const clearFilters = () => {
setFilterNome("");
setFilterAreaId("");
setCurrentPage(1);
};
const hasActiveFilters = filterNome.trim() !== "" || filterAreaId !== "";
const handleItemsPerPageChange = (value: string) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
};
const openDetailsDialog = (prompt: Prompt) => {
setSelectedPrompt(prompt);
setIsDetailsDialogOpen(true);
};
const openEditFromDetails = () => {
if (!selectedPrompt) return;
setIsDetailsDialogOpen(false);
navigate(`/commander/prompts/${selectedPrompt.id}`);
};
const openDeleteDialog = (prompt: Prompt) => {
setPromptToDelete(prompt);
setIsDeleteDialogOpen(true);
};
const handleDeletePrompt = async () => {
if (!promptToDelete) return;
setIsDeletingPrompt(true);
try {
await promptsService.deletar(promptToDelete.id);
setPromptsList((prev) => prev.filter((p) => p.id !== promptToDelete.id));
setPrompts((prev) => prev.filter((p) => p.id !== promptToDelete.id));
setTotalRegistros((prev) => Math.max(0, prev - 1));
if (selectedPrompt?.id === promptToDelete.id) {
setIsDetailsDialogOpen(false);
setSelectedPrompt(null);
}
toast.success("Prompt excluído com sucesso!");
setIsDeleteDialogOpen(false);
setPromptToDelete(null);
setCurrentPage(1);
} catch (e: unknown) {
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir prompt.";
toast.error(msg);
} finally {
setIsDeletingPrompt(false);
}
};
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
<FileText className="w-5 h-5 md:w-6 md:h-6" />
Prompts
</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/commander/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo prompt</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar prompts..."
value={filterNome}
onChange={(e) => setFilterNome(e.target.value)}
className="w-full md:max-w-sm text-sm"
/>
<Select value={filterAreaId || "all"} onValueChange={(v) => setFilterAreaId(v === "all" ? "" : v)}>
<SelectTrigger className="w-full md:w-[180px] text-sm">
<SelectValue placeholder="Todas as áreas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as áreas</SelectItem>
{areaItems.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.nome}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
Nenhum prompt cadastrado
</CardTitle>
<CardDescription>
Crie seu primeiro prompt para organizar por título e área.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<Button onClick={() => navigate("/commander/prompts/novo")}>
<Plus className="w-4 h-4 mr-2" />
Novo prompt
</Button>
</div>
</CardContent>
</Card>
) : (
<>
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px] whitespace-nowrap font-semibold text-xs md:text-sm">
Data de criação
</TableHead>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-10 text-base">
{hasActiveFilters ? "Nenhum prompt encontrado para os filtros selecionados." : "Não há resultados."}
</TableCell>
</TableRow>
) : (
promptsList.map((prompt) => (
<TableRow key={prompt.id}>
<TableCell className="text-xs md:text-sm whitespace-nowrap tabular-nums">
{formatarDataCriacao(prompt.created_at)}
</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{prompt.titulo}</TableCell>
<TableCell className="text-xs md:text-sm">{prompt.area}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openDetailsDialog(prompt)}
title="Detalhes"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
>
<Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
title="Editar"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
>
<Edit className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openDeleteDialog(prompt)}
title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive hover:bg-slate-300 dark:hover:bg-slate-700"
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loadingList && totalRegistros > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {totalRegistros} {totalRegistros === 1 ? "prompt" : "prompts"}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page: number;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</>
)}
</div>
{/* Modal Detalhes */}
<Dialog open={isDetailsDialogOpen} onOpenChange={setIsDetailsDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Detalhes do Prompt</DialogTitle>
<DialogDescription>
Informações do prompt selecionado
</DialogDescription>
</DialogHeader>
{selectedPrompt && (
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 p-3">
{selectedPrompt.created_at?.trim() ? (
<div>
<Label className="text-muted-foreground">Data de criação</Label>
<p className="mt-1 text-sm font-medium tabular-nums">{formatarDataCriacao(selectedPrompt.created_at)}</p>
</div>
) : null}
<div>
<Label className="text-muted-foreground">Título</Label>
<p className="font-medium mt-1">{selectedPrompt.titulo}</p>
</div>
<div>
<Label className="text-muted-foreground">Área</Label>
<p className="mt-1 font-medium">{selectedPrompt.area}</p>
{selectedAreaItem?.descricao?.trim() && (
<p className="mt-1 text-sm text-muted-foreground">{selectedAreaItem.descricao}</p>
)}
</div>
{selectedPrompt.conteudo != null && selectedPrompt.conteudo !== "" && (
<div>
<Label className="text-muted-foreground">Conteúdo</Label>
<div className="mt-1 rounded-md border bg-muted/30 p-4 max-h-[50vh] overflow-y-auto">
<p className="whitespace-pre-wrap text-sm leading-relaxed">{selectedPrompt.conteudo}</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={async () => {
if (selectedPrompt?.conteudo != null) {
await navigator.clipboard.writeText(selectedPrompt.conteudo);
toast.success("Prompt copiado para a área de transferência.");
} else {
toast.error("Nenhum conteúdo para copiar.");
}
}}
disabled={!selectedPrompt?.conteudo?.trim()}
>
<Copy className="w-4 h-4 mr-2" />
Copiar prompt
</Button>
<Button variant="outline" onClick={() => setIsDetailsDialogOpen(false)}>
Fechar
</Button>
<Button onClick={openEditFromDetails}>
<Edit className="w-4 h-4 mr-2" />
Editar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir prompt</AlertDialogTitle>
<AlertDialogDescription>
Deseja excluir o prompt "{promptToDelete?.titulo ?? ""}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingPrompt}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeletePrompt}
disabled={isDeletingPrompt || !promptToDelete}
className={(isDeletingPrompt || !promptToDelete) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
>
{isDeletingPrompt ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
+183
View File
@@ -0,0 +1,183 @@
import { RefObject, useEffect, useId, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface AnimatedBeamProps {
className?: string
containerRef: RefObject<HTMLElement | null> // Container ref
fromRef: RefObject<HTMLElement | null>
toRef: RefObject<HTMLElement | null>
curvature?: number
reverse?: boolean
pathColor?: string
pathWidth?: number
pathOpacity?: number
gradientStartColor?: string
gradientStopColor?: string
delay?: number
duration?: number
startXOffset?: number
startYOffset?: number
endXOffset?: number
endYOffset?: number
}
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
className,
containerRef,
fromRef,
toRef,
curvature = 0,
reverse = false, // Include the reverse prop
duration = Math.random() * 3 + 4,
delay = 0,
pathColor = "gray",
pathWidth = 2,
pathOpacity = 0.2,
gradientStartColor = "#ffaa40",
gradientStopColor = "#9c40ff",
startXOffset = 0,
startYOffset = 0,
endXOffset = 0,
endYOffset = 0,
}) => {
const id = useId()
const [pathD, setPathD] = useState("")
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 })
// Calculate the gradient coordinates based on the reverse prop
const gradientCoordinates = reverse
? {
x1: ["90%", "-10%"],
x2: ["100%", "0%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
}
: {
x1: ["10%", "110%"],
x2: ["0%", "100%"],
y1: ["0%", "0%"],
y2: ["0%", "0%"],
}
useEffect(() => {
const updatePath = () => {
if (containerRef.current && fromRef.current && toRef.current) {
const containerRect = containerRef.current.getBoundingClientRect()
const rectA = fromRef.current.getBoundingClientRect()
const rectB = toRef.current.getBoundingClientRect()
const svgWidth = containerRect.width
const svgHeight = containerRect.height
setSvgDimensions({ width: svgWidth, height: svgHeight })
const startX =
rectA.left - containerRect.left + rectA.width / 2 + startXOffset
const startY =
rectA.top - containerRect.top + rectA.height / 2 + startYOffset
const endX =
rectB.left - containerRect.left + rectB.width / 2 + endXOffset
const endY =
rectB.top - containerRect.top + rectB.height / 2 + endYOffset
const controlY = startY - curvature
const d = `M ${startX},${startY} Q ${
(startX + endX) / 2
},${controlY} ${endX},${endY}`
setPathD(d)
}
}
// Initialize ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updatePath()
})
// Observe the container element
if (containerRef.current) {
resizeObserver.observe(containerRef.current)
}
// Call the updatePath initially to set the initial path
updatePath()
// Clean up the observer on component unmount
return () => {
resizeObserver.disconnect()
}
}, [
containerRef,
fromRef,
toRef,
curvature,
startXOffset,
startYOffset,
endXOffset,
endYOffset,
])
return (
<svg
fill="none"
width={svgDimensions.width}
height={svgDimensions.height}
xmlns="http://www.w3.org/2000/svg"
className={cn(
"pointer-events-none absolute top-0 left-0 transform-gpu stroke-2",
className
)}
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
>
<path
d={pathD}
stroke={pathColor}
strokeWidth={pathWidth}
strokeOpacity={pathOpacity}
strokeLinecap="round"
/>
<path
d={pathD}
strokeWidth={pathWidth}
stroke={`url(#${id})`}
strokeOpacity="1"
strokeLinecap="round"
/>
<defs>
<motion.linearGradient
className="transform-gpu"
id={id}
gradientUnits={"userSpaceOnUse"}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: gradientCoordinates.x1,
x2: gradientCoordinates.x2,
y1: gradientCoordinates.y1,
y2: gradientCoordinates.y2,
}}
transition={{
delay,
duration,
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
repeat: Infinity,
repeatDelay: 0,
}}
>
<stop stopColor={gradientStartColor} stopOpacity="0"></stop>
<stop stopColor={gradientStartColor}></stop>
<stop offset="32.5%" stopColor={gradientStopColor}></stop>
<stop
offset="100%"
stopColor={gradientStopColor}
stopOpacity="0"
></stop>
</motion.linearGradient>
</defs>
</svg>
)
}
@@ -0,0 +1,38 @@
import { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
speed?: number
colorFrom?: string
colorTo?: string
}
export function AnimatedGradientText({
children,
className,
speed = 1,
colorFrom = "#0055a4",
colorTo = "#0d8ecf",
style,
...props
}: AnimatedGradientTextProps) {
const bgSize = `${speed * 300}%`
return (
<span
className={cn("animated-gradient-text inline-block font-semibold", className)}
style={
{
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
backgroundSize: `${bgSize} 100%`,
animation: "gradient-text-shift 12s ease-in-out infinite",
...style,
} as CSSProperties
}
{...props}
>
{children}
</span>
)
}
+175
View File
@@ -0,0 +1,175 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
} from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
export interface AnimatedGridPatternProps extends ComponentPropsWithoutRef<"svg"> {
width?: number
height?: number
x?: number
y?: number
strokeDasharray?: number
numSquares?: number
maxOpacity?: number
duration?: number
repeatDelay?: number
}
type Square = {
id: number
pos: [number, number]
iteration: number
}
export function AnimatedGridPattern({
width = 40,
height = 40,
x = -1,
y = -1,
strokeDasharray = 0,
numSquares = 50,
className,
maxOpacity = 0.5,
duration = 4,
repeatDelay = 0.5,
...props
}: AnimatedGridPatternProps) {
const id = useId()
const containerRef = useRef<SVGSVGElement | null>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
const [squares, setSquares] = useState<Array<Square>>([])
const getPos = useCallback((): [number, number] => {
return [
Math.floor((Math.random() * dimensions.width) / width),
Math.floor((Math.random() * dimensions.height) / height),
]
}, [dimensions.height, dimensions.width, height, width])
const generateSquares = useCallback(
(count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i,
pos: getPos(),
iteration: 0,
}))
},
[getPos]
)
const updateSquarePosition = useCallback(
(squareId: number) => {
setSquares((currentSquares) => {
const current = currentSquares[squareId]
if (!current || current.id !== squareId) return currentSquares
const nextSquares = currentSquares.slice()
nextSquares[squareId] = {
...current,
pos: getPos(),
iteration: current.iteration + 1,
}
return nextSquares
})
},
[getPos]
)
useEffect(() => {
if (dimensions.width && dimensions.height) {
setSquares(generateSquares(numSquares))
}
}, [dimensions.width, dimensions.height, generateSquares, numSquares])
useEffect(() => {
const element = containerRef.current
let resizeObserver: ResizeObserver | null = null
if (element) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setDimensions((currentDimensions) => {
const nextWidth = entry.contentRect.width
const nextHeight = entry.contentRect.height
if (
currentDimensions.width === nextWidth &&
currentDimensions.height === nextHeight
) {
return currentDimensions
}
return { width: nextWidth, height: nextHeight }
})
}
})
resizeObserver.observe(element)
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect()
}
}
}, [])
return (
<svg
ref={containerRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
className
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path
d={`M.5 ${height}V.5H${width}`}
fill="none"
strokeDasharray={strokeDasharray}
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
<svg x={x} y={y} className="overflow-visible">
{squares.map(({ pos: [squareX, squareY], id, iteration }, index) => (
<motion.rect
initial={{ opacity: 0 }}
animate={{ opacity: maxOpacity }}
transition={{
duration,
repeat: 1,
delay: index * 0.1,
repeatType: "reverse",
repeatDelay,
}}
onAnimationComplete={() => updateSquarePosition(id)}
key={`${id}-${iteration}`}
width={width - 1}
height={height - 1}
x={squareX * width + 1}
y={squareY * height + 1}
fill="currentColor"
strokeWidth="0"
/>
))}
</svg>
</svg>
)
}
+42
View File
@@ -0,0 +1,42 @@
import {
type ComponentPropsWithoutRef,
type CSSProperties,
type FC,
} from "react"
import { cn } from "@/lib/utils"
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
shimmerWidth?: number
}
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
children,
className,
shimmerWidth = 100,
...props
}) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-size-[var(--shiny-width)_100%] bg-clip-text bg-position-[0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className
)}
{...props}
>
{children}
</span>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { CSSProperties } from "react";
import { motion, type MotionStyle, type Transition } from "motion/react";
import { cn } from "@/lib/utils";
interface BorderBeamProps {
size?: number;
duration?: number;
delay?: number;
colorFrom?: string;
colorTo?: string;
transition?: Transition;
className?: string;
style?: CSSProperties;
reverse?: boolean;
initialOffset?: number;
borderWidth?: number;
}
/**
* Border Beam (Magic UI) — mesma lógica do registry oficial; estilos de máscara/borda
* em inline CSS para compatibilidade com Tailwind 3 (o doc do Magic UI usa utilitários v4).
*/
export const BorderBeam = ({
className,
size = 50,
delay = 0,
duration = 6,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
transition,
style,
reverse = false,
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
const maskWrapperStyle: CSSProperties = {
borderWidth: `${borderWidth}px`,
borderStyle: "solid",
borderColor: "transparent",
maskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
maskClip: "padding-box, border-box",
maskComposite: "intersect",
maskRepeat: "no-repeat",
WebkitMaskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
WebkitMaskClip: "padding-box, border-box",
WebkitMaskComposite: "source-in",
};
const beamGradient: CSSProperties = {
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
backgroundImage: `linear-gradient(to left, ${colorFrom}, ${colorTo}, transparent)`,
};
return (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={maskWrapperStyle}
>
<motion.div
className={cn("absolute aspect-square", className)}
style={{ ...beamGradient, ...style } as MotionStyle}
initial={{ offsetDistance: `${initialOffset}%` }}
animate={{
offsetDistance: reverse
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
: [`${initialOffset}%`, `${100 + initialOffset}%`],
}}
transition={{
repeat: Infinity,
ease: "linear",
duration,
delay: -delay,
...transition,
}}
/>
</div>
);
};
+1 -1
View File
@@ -39,7 +39,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />; return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
}, },
); );
Button.displayName = "Button"; Button.displayName = "Button";
+145
View File
@@ -0,0 +1,145 @@
"use client";
import type { ReactNode } from "react";
import React, {
createContext,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import type {
GlobalOptions as ConfettiGlobalOptions,
CreateTypes as ConfettiInstance,
Options as ConfettiOptions,
} from "canvas-confetti";
import confetti from "canvas-confetti";
import { Button } from "@/components/ui/button";
type Api = {
fire: (options?: ConfettiOptions) => void;
};
type Props = React.ComponentPropsWithRef<"canvas"> & {
options?: ConfettiOptions;
globalOptions?: ConfettiGlobalOptions;
manualstart?: boolean;
children?: ReactNode;
};
export type ConfettiRef = Api | null;
const ConfettiContext = createContext<Api>({} as Api);
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
const {
options,
globalOptions = { resize: true, useWorker: true },
manualstart = false,
children,
...rest
} = props;
const instanceRef = useRef<ConfettiInstance | null>(null);
const canvasRef = useCallback(
(node: HTMLCanvasElement) => {
if (node !== null) {
if (instanceRef.current) return;
instanceRef.current = confetti.create(node, {
...globalOptions,
resize: true,
});
} else {
if (instanceRef.current) {
instanceRef.current.reset();
instanceRef.current = null;
}
}
},
[globalOptions]
);
const fire = useCallback(
async (opts = {}) => {
try {
await instanceRef.current?.({ ...options, ...opts });
} catch (error) {
console.error("Confetti error:", error);
}
},
[options]
);
const api = useMemo(
() => ({
fire,
}),
[fire]
);
useImperativeHandle(ref, () => api, [api]);
useEffect(() => {
if (!manualstart) {
(async () => {
try {
await fire();
} catch (error) {
console.error("Confetti effect error:", error);
}
})();
}
}, [manualstart, fire]);
return (
<ConfettiContext.Provider value={api}>
<canvas ref={canvasRef} {...rest} />
{children}
</ConfettiContext.Provider>
);
});
ConfettiComponent.displayName = "Confetti";
export const Confetti = ConfettiComponent;
interface ConfettiButtonProps extends React.ComponentProps<"button"> {
options?: ConfettiOptions &
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
}
const ConfettiButtonComponent = ({
options,
children,
...props
}: ConfettiButtonProps) => {
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
try {
const rect = event.currentTarget.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
await confetti({
...options,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
});
} catch (error) {
console.error("Confetti button error:", error);
}
};
return (
<Button onClick={handleClick} {...props}>
{children}
</Button>
);
};
ConfettiButtonComponent.displayName = "ConfettiButton";
export const ConfettiButton = ConfettiButtonComponent;
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useRef, useState } from "react"
import {
animate,
motion,
useInView,
useMotionValue,
useReducedMotion,
useTransform,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
const BAND_HALF = 17
const SWEEP_START = -BAND_HALF
const SWEEP_END = 100 + BAND_HALF
const sweepEase = (t: number) =>
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF
const bandEnd = pos + BAND_HALF
if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})`
}
const n = colors.length
const parts: string[] = []
if (bandStart > 0)
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
parts.push(`${c} ${pct.toFixed(2)}%`)
})
if (bandEnd < 100)
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
return `linear-gradient(90deg, ${parts.join(", ")})`
}
function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement
Object.assign(ghost.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
width: "auto",
whiteSpace: "nowrap",
})
el.parentElement!.appendChild(ghost)
const widths = texts.map((t) => {
ghost.textContent = t
return ghost.getBoundingClientRect().width
})
ghost.remove()
return widths
}
/**
* Props for {@link DiaTextReveal}.
*/
export interface DiaTextRevealProps extends Omit<
HTMLMotionProps<"span">,
"ref" | "children" | "style" | "animate" | "transition" | "color"
> {
/**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/
text: string | string[]
/**
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/
colors?: string[]
/**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"`
*/
textColor?: string
/**
* Duration of one sweep pass, in seconds.
* @defaultValue `1.5`
*/
duration?: number
/**
* Delay before the sweep starts, in seconds.
* @defaultValue `0`
*/
delay?: number
/**
* When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false`
*/
repeat?: boolean
/**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5`
*/
repeatDelay?: number
/**
* If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true`
*/
startOnView?: boolean
/**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true`
*/
once?: boolean
/**
* Additional class names for the animated `span` (e.g. typography utilities).
*/
className?: string
/**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false`
*/
fixedWidth?: boolean
}
export function DiaTextReveal({
text,
colors = DEFAULT_COLORS,
textColor = "var(--foreground)",
duration = 1.5,
delay = 0,
repeat = false,
repeatDelay = 0.5,
startOnView = true,
once = true,
className,
fixedWidth = false,
...props
}: DiaTextRevealProps) {
const texts = Array.isArray(text) ? text : [text]
const isMulti = texts.length > 1
const prefersReducedMotion = useReducedMotion()
const spanRef = useRef<HTMLSpanElement>(null)
const optsRef = useRef({
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
})
optsRef.current = {
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
}
const indexRef = useRef(0)
const hasPlayedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const playRef = useRef<() => void>(null!)
const stopRef = useRef<(() => void) | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
const sweepPos = useMotionValue(SWEEP_START)
const backgroundImage = useTransform(sweepPos, (pos) =>
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
)
const isInView = useInView(spanRef, { once, amount: 0.1 })
useEffect(() => {
const el = spanRef.current
if (!el || !isMulti) return
setMeasuredWidths(measureWidths(el, texts))
}, [Array.isArray(text) ? text.join("\0") : text])
playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
sweepPos.set(SWEEP_START)
const controls = animate(sweepPos, SWEEP_END, {
duration,
delay,
ease: sweepEase,
onComplete() {
if (!repeat) return
timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length
indexRef.current = next
setActiveIndex(next)
playRef.current()
}, repeatDelay * 1000)
},
})
stopRef.current = () => controls.stop()
}
useEffect(() => {
if (prefersReducedMotion) {
sweepPos.set(SWEEP_END)
return
}
if (startOnView && !isInView) return
if (once && hasPlayedRef.current) return
hasPlayedRef.current = true
playRef.current()
return () => {
stopRef.current?.()
clearTimeout(timerRef.current)
}
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
const fixedW =
isMulti && fixedWidth && measuredWidths.length > 0
? Math.max(...measuredWidths)
: undefined
const animatedW =
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
? measuredWidths[activeIndex]
: undefined
return (
<motion.span
ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{
transform: "translateY(-2px)",
color: "transparent",
backgroundClip: "text",
WebkitBackgroundClip: "text",
backgroundSize: "100% 100%",
backgroundImage,
...(isMulti && {
display: "inline-block",
overflow: "hidden",
whiteSpace: "nowrap",
verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }),
}),
}}
animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props}
>
{texts[activeIndex]}
</motion.span>
)
}
+11 -3
View File
@@ -27,12 +27,18 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
hideClose?: boolean;
overlayClassName?: string;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> DialogContentProps
>(({ className, children, ...props }, ref) => ( >(({ className, children, hideClose, overlayClassName, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
@@ -42,10 +48,12 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); ));
@@ -0,0 +1,30 @@
import { ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function InteractiveHoverButton({
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
className={cn(
"group bg-background relative w-auto cursor-pointer overflow-hidden rounded-full border p-2 px-6 text-center font-semibold",
className
)}
{...props}
>
<div className="flex items-center justify-center gap-2">
<div className="bg-primary h-2 w-2 rounded-full transition-all duration-300 group-hover:scale-[100.8]"></div>
<span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
{children}
</span>
</div>
<div className="text-primary-foreground absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span>{children}</span>
<ArrowRight />
</div>
</button>
)
}
+193
View File
@@ -0,0 +1,193 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent,
type ReactNode,
} from "react";
import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface MagicCardBaseProps {
children?: ReactNode;
className?: string;
gradientSize?: number;
gradientFrom?: string;
gradientTo?: string;
}
interface MagicCardGradientProps extends MagicCardBaseProps {
mode?: "gradient";
glowFrom?: never;
glowTo?: never;
glowAngle?: never;
glowSize?: never;
glowBlur?: never;
glowOpacity?: never;
}
interface MagicCardOrbProps extends MagicCardBaseProps {
mode: "orb";
glowFrom?: string;
glowTo?: string;
glowAngle?: number;
glowSize?: number;
glowBlur?: number;
glowOpacity?: number;
}
export type MagicCardProps = MagicCardGradientProps | MagicCardOrbProps;
type ResetReason = "enter" | "leave" | "global" | "init";
function isOrbMode(props: MagicCardProps): props is MagicCardOrbProps {
return props.mode === "orb";
}
export function MagicCard(props: MagicCardProps) {
const {
children,
className,
gradientSize = 200,
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
mode = "gradient",
} = props;
const glowFrom = isOrbMode(props) ? (props.glowFrom ?? "#ee4f27") : "#ee4f27";
const glowTo = isOrbMode(props) ? (props.glowTo ?? "#6b21ef") : "#6b21ef";
const glowAngle = isOrbMode(props) ? (props.glowAngle ?? 90) : 90;
const glowSize = isOrbMode(props) ? (props.glowSize ?? 420) : 420;
const glowBlur = isOrbMode(props) ? (props.glowBlur ?? 60) : 60;
const glowOpacity = isOrbMode(props) ? (props.glowOpacity ?? 0.9) : 0.9;
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDarkTheme = useMemo(() => {
if (!mounted) return true;
const currentTheme = theme === "system" ? systemTheme : theme;
return currentTheme === "dark";
}, [theme, systemTheme, mounted]);
const mouseX = useMotionValue(-gradientSize);
const mouseY = useMotionValue(-gradientSize);
const orbX = useSpring(mouseX, { stiffness: 250, damping: 30, mass: 0.6 });
const orbY = useSpring(mouseY, { stiffness: 250, damping: 30, mass: 0.6 });
const orbVisible = useSpring(0, { stiffness: 300, damping: 35 });
const modeRef = useRef(mode);
const glowOpacityRef = useRef(glowOpacity);
const gradientSizeRef = useRef(gradientSize);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
glowOpacityRef.current = glowOpacity;
}, [glowOpacity]);
useEffect(() => {
gradientSizeRef.current = gradientSize;
}, [gradientSize]);
const reset = useCallback(
(reason: ResetReason = "leave") => {
const currentMode = modeRef.current;
if (currentMode === "orb") {
if (reason === "enter") orbVisible.set(glowOpacityRef.current);
else orbVisible.set(0);
return;
}
const off = -gradientSizeRef.current;
mouseX.set(off);
mouseY.set(off);
},
[mouseX, mouseY, orbVisible],
);
const handlePointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
},
[mouseX, mouseY],
);
useEffect(() => {
reset("init");
}, [reset]);
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) reset("global");
};
const handleBlur = () => reset("global");
const handleVisibility = () => {
if (document.visibilityState !== "visible") reset("global");
};
window.addEventListener("pointerout", handleGlobalPointerOut);
window.addEventListener("blur", handleBlur);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.removeEventListener("pointerout", handleGlobalPointerOut);
window.removeEventListener("blur", handleBlur);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [reset]);
const bgMotion = useMotionTemplate`
linear-gradient(hsl(var(--background)) 0 0) padding-box,
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
hsl(var(--border)) 100%
) border-box
`;
return (
<motion.div
className={cn("relative isolate overflow-hidden rounded-[inherit] border border-transparent", className)}
onPointerMove={handlePointerMove}
onPointerLeave={() => reset("leave")}
onPointerEnter={() => reset("enter")}
style={{ background: bgMotion }}
>
<div className="absolute inset-px z-20 rounded-[inherit] bg-background" />
{mode === "orb" && (
<motion.div
suppressHydrationWarning
aria-hidden
className="pointer-events-none absolute z-30"
style={{
width: glowSize,
height: glowSize,
x: orbX,
y: orbY,
translateX: "-50%",
translateY: "-50%",
borderRadius: 9999,
filter: `blur(${glowBlur}px)`,
opacity: orbVisible,
background: `linear-gradient(${glowAngle}deg, ${glowFrom}, ${glowTo})`,
mixBlendMode: isDarkTheme ? "screen" : "multiply",
willChange: "transform, opacity",
}}
/>
)}
<div className="relative z-40">{children}</div>
</motion.div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import {
CSSProperties,
ReactElement,
ReactNode,
useEffect,
useRef,
useState,
} from "react"
import { cn } from "@/lib/utils"
interface NeonColorsProps {
firstColor: string
secondColor: string
}
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the card
* */
as?: ReactElement
/**
* @default ""
* @type string
* @description
* The className of the card
*/
className?: string
/**
* @default ""
* @type ReactNode
* @description
* The children of the card
* */
children?: ReactNode
/**
* @default 5
* @type number
* @description
* The size of the border in pixels
* */
borderSize?: number
/**
* @default 20
* @type number
* @description
* The size of the radius in pixels
* */
borderRadius?: number
/**
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
* @type string
* @description
* The colors of the neon gradient
* */
neonColors?: NeonColorsProps
}
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
className,
children,
borderSize = 2,
borderRadius = 20,
neonColors = {
firstColor: "#ff00aa",
secondColor: "#00FFF1",
},
...props
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}
updateDimensions()
window.addEventListener("resize", updateDimensions)
return () => {
window.removeEventListener("resize", updateDimensions)
}
}, [])
useEffect(() => {
if (containerRef.current) {
const { offsetWidth, offsetHeight } = containerRef.current
setDimensions({ width: offsetWidth, height: offsetHeight })
}
}, [children])
return (
<div
ref={containerRef}
style={
{
"--border-size": `${borderSize}px`,
"--border-radius": `${borderRadius}px`,
"--neon-first-color": neonColors.firstColor,
"--neon-second-color": neonColors.secondColor,
"--card-width": `${dimensions.width}px`,
"--card-height": `${dimensions.height}px`,
"--card-content-radius": `${borderRadius - borderSize}px`,
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
"--after-blur": `${dimensions.width / 3}px`,
} as CSSProperties
}
className={cn(
"relative z-10 size-full rounded-(--border-radius)",
className
)}
{...props}
>
<div
className={cn(
"relative size-full min-h-[inherit] rounded-(--card-content-radius) bg-gray-100 p-6",
"before:absolute before:-top-(--border-size) before:-left-(--border-size) before:-z-10 before:block",
"before:h-(--pseudo-element-height) before:w-(--pseudo-element-width) before:rounded-(--border-radius) before:content-['']",
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-size-[100%_200%]",
"before:animate-background-position-spin",
"after:absolute after:-top-(--border-size) after:-left-(--border-size) after:-z-10 after:block",
"after:h-(--pseudo-element-height) after:w-(--pseudo-element-width) after:rounded-(--border-radius) after:blur-(--after-blur) after:content-['']",
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-size-[100%_200%] after:opacity-80",
"after:animate-background-position-spin",
"dark:bg-neutral-900",
"wrap-break-word"
)}
>
{children}
</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const rainbowButtonVariants = cva(
cn(
"relative cursor-pointer group transition-all animate-rainbow",
"inline-flex items-center justify-center gap-2 shrink-0",
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
"text-sm font-medium whitespace-nowrap",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
),
{
variants: {
variant: {
default:
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
outline:
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-xl px-3 text-xs",
lg: "h-11 rounded-xl px-8",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
interface RainbowButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof rainbowButtonVariants> {
asChild?: boolean
}
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(rainbowButtonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
RainbowButton.displayName = "RainbowButton"
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
+99
View File
@@ -0,0 +1,99 @@
import React, { MouseEvent, useEffect, useState } from "react"
import { cn } from "@/lib/utils"
interface RippleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
rippleColor?: string
duration?: string
}
export const RippleButton = React.forwardRef<
HTMLButtonElement,
RippleButtonProps
>(
(
{
className,
children,
rippleColor = "#ffffff",
duration = "600ms",
onClick,
...props
},
ref
) => {
const [buttonRipples, setButtonRipples] = useState<
Array<{ x: number; y: number; size: number; key: number }>
>([])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
createRipple(event)
onClick?.(event)
}
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
const button = event.currentTarget
const rect = button.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const x = event.clientX - rect.left - size / 2
const y = event.clientY - rect.top - size / 2
const newRipple = { x, y, size, key: Date.now() }
setButtonRipples((prevRipples) => [...prevRipples, newRipple])
}
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1]
timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key)
)
}, parseInt(duration))
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [buttonRipples, duration])
return (
<button
className={cn(
"bg-background text-primary relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 px-4 py-2 text-center",
className
)}
onClick={handleClick}
ref={ref}
{...props}
>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="animate-rippling bg-background absolute rounded-full opacity-30"
key={ripple.key}
style={
{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
"--duration": duration,
} as React.CSSProperties
}
/>
))}
</span>
</button>
)
}
)
RippleButton.displayName = "RippleButton"
+58
View File
@@ -0,0 +1,58 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
interface RippleProps extends ComponentPropsWithoutRef<"div"> {
mainCircleSize?: number
mainCircleOpacity?: number
numCircles?: number
}
export const Ripple = React.memo(function Ripple({
mainCircleSize = 210,
mainCircleOpacity = 0.24,
numCircles = 8,
className,
...props
}: RippleProps) {
return (
<div
className={cn(
"pointer-events-none absolute inset-0 mask-[linear-gradient(to_bottom,white,transparent)] select-none",
className
)}
{...props}
>
{Array.from({ length: numCircles }, (_, i) => {
const size = mainCircleSize + i * 70
const opacity = mainCircleOpacity - i * 0.03
const animationDelay = `${i * 0.06}s`
const borderStyle = "solid"
return (
<div
key={i}
className={`animate-ripple bg-foreground/25 absolute rounded-full border shadow-xl`}
style={
{
"--i": i,
width: `${size}px`,
height: `${size}px`,
opacity,
animationDelay,
borderStyle,
borderWidth: "1px",
borderColor: `var(--foreground)`,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%) scale(1)",
} as CSSProperties
}
/>
)
})}
</div>
)
})
Ripple.displayName = "Ripple"
+4 -1
View File
@@ -8,7 +8,9 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}> <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport> <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block [&>div]:min-w-0 [&>div]:w-full [&>div]:max-w-full">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
@@ -36,3 +38,4 @@ const ScrollBar = React.forwardRef<
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar }; export { ScrollArea, ScrollBar };
+96
View File
@@ -0,0 +1,96 @@
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils"
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
shimmerColor?: string
shimmerSize?: string
borderRadius?: string
shimmerDuration?: string
background?: string
className?: string
children?: React.ReactNode
}
export const ShimmerButton = React.forwardRef<
HTMLButtonElement,
ShimmerButtonProps
>(
(
{
shimmerColor = "#ffffff",
shimmerSize = "0.05em",
shimmerDuration = "3s",
borderRadius = "100px",
background = "rgba(0, 0, 0, 1)",
className,
children,
...props
},
ref
) => {
return (
<button
style={
{
"--spread": "90deg",
"--shimmer-color": shimmerColor,
"--radius": borderRadius,
"--speed": shimmerDuration,
"--cut": shimmerSize,
"--bg": background,
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-border px-6 py-3 whitespace-nowrap text-card-foreground [background:var(--bg)]",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className
)}
ref={ref}
{...props}
>
{/* spark container */}
<div
className={cn(
"-z-30 blur-[2px]",
"@container-[size] absolute inset-0 overflow-visible"
)}
>
{/* spark */}
<div className="animate-shimmer-slide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
{/* spark before */}
<div className="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" />
</div>
</div>
{children}
{/* Highlight */}
<div
className={cn(
"absolute inset-0 size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_hsl(var(--foreground)_/_0.08)]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_hsl(var(--foreground)_/_0.14)]",
// on click
"group-active:shadow-[inset_0_-10px_10px_hsl(var(--foreground)_/_0.14)]"
)}
/>
{/* backdrop */}
<div
className={cn(
"absolute inset-(--cut) -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
)}
/>
</button>
)
}
)
ShimmerButton.displayName = "ShimmerButton"
+148
View File
@@ -0,0 +1,148 @@
import { CSSProperties, ReactElement, useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
interface Sparkle {
id: string
x: string
y: string
color: string
delay: number
scale: number
lifespan: number
}
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
return (
<motion.svg
key={id}
className="pointer-events-none absolute z-20"
initial={{ opacity: 0, left: x, top: y }}
animate={{
opacity: [0, 1, 0],
scale: [0, scale, 0],
rotate: [75, 120, 150],
}}
transition={{ duration: 0.8, repeat: Infinity, delay }}
width="21"
height="21"
viewBox="0 0 21 21"
>
<path
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
fill={color}
/>
</motion.svg>
)
}
interface SparklesTextProps {
/**
* @default <div />
* @type ReactElement
* @description
* The component to be rendered as the text
* */
as?: ReactElement
/**
* @default ""
* @type string
* @description
* The className of the text
*/
className?: string
/**
* @required
* @type ReactNode
* @description
* The content to be displayed
* */
children: React.ReactNode
/**
* @default 10
* @type number
* @description
* The count of sparkles
* */
sparklesCount?: number
/**
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
* @type string
* @description
* The colors of the sparkles
* */
colors?: {
first: string
second: string
}
}
export const SparklesText: React.FC<SparklesTextProps> = ({
children,
colors = { first: "#9E7AFF", second: "#FE8BBB" },
className,
sparklesCount = 10,
...props
}) => {
const [sparkles, setSparkles] = useState<Sparkle[]>([])
useEffect(() => {
const generateStar = (): Sparkle => {
const starX = `${Math.random() * 100}%`
const starY = `${Math.random() * 100}%`
const color = Math.random() > 0.5 ? colors.first : colors.second
const delay = Math.random() * 2
const scale = Math.random() * 1 + 0.3
const lifespan = Math.random() * 10 + 5
const id = `${starX}-${starY}-${Date.now()}`
return { id, x: starX, y: starY, color, delay, scale, lifespan }
}
const initializeStars = () => {
const newSparkles = Array.from({ length: sparklesCount }, generateStar)
setSparkles(newSparkles)
}
const updateStars = () => {
setSparkles((currentSparkles) =>
currentSparkles.map((star) => {
if (star.lifespan <= 0) {
return generateStar()
} else {
return { ...star, lifespan: star.lifespan - 0.1 }
}
})
)
}
initializeStars()
const interval = setInterval(updateStars, 100)
return () => clearInterval(interval)
}, [colors.first, colors.second, sparklesCount])
return (
<div
className={cn("text-6xl font-bold", className)}
{...props}
style={
{
"--sparkles-first-color": `${colors.first}`,
"--sparkles-second-color": `${colors.second}`,
} as CSSProperties
}
>
<span className="relative inline-block">
{sparkles.map((sparkle) => (
<Sparkle key={sparkle.id} {...sparkle} />
))}
<strong>{children}</strong>
</span>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* Container com estilo de terminal: fundo escuro, borda, fonte monospace.
*/
const Terminal = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-zinc-900/95 font-mono text-sm shadow-xl",
"p-4 text-foreground",
className
)}
{...props}
>
{children}
</div>
));
Terminal.displayName = "Terminal";
interface AnimatedStepProps extends React.HTMLAttributes<HTMLSpanElement> {
delay?: number;
className?: string;
children?: React.ReactNode;
}
/**
* Linha animada (fade-in + slide up) para uso dentro do Terminal.
* Usado para exibir passos em sequência.
*/
function AnimatedStep({
delay = 0,
className,
children,
...props
}: AnimatedStepProps) {
return (
<motion.span
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay }}
className={cn("block", className)}
{...props}
>
{children}
</motion.span>
);
}
export { Terminal, AnimatedStep };
+234
View File
@@ -0,0 +1,234 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type RefAttributes,
type RefObject,
} from "react"
import {
motion,
useInView,
type DOMMotionComponents,
type HTMLMotionProps,
type MotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const motionElements = {
article: motion.article,
div: motion.div,
h1: motion.h1,
h2: motion.h2,
h3: motion.h3,
h4: motion.h4,
h5: motion.h5,
h6: motion.h6,
li: motion.li,
p: motion.p,
section: motion.section,
span: motion.span,
} as const
type MotionElementType = Extract<
keyof DOMMotionComponents,
keyof typeof motionElements
>
type TypingAnimationMotionComponent = ComponentType<
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
>
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
children?: string
words?: string[]
className?: string
duration?: number
typeSpeed?: number
deleteSpeed?: number
delay?: number
pauseDelay?: number
loop?: boolean
as?: MotionElementType
startOnView?: boolean
showCursor?: boolean
blinkCursor?: boolean
cursorStyle?: "line" | "block" | "underscore"
}
export function TypingAnimation({
children,
words,
className,
duration = 100,
typeSpeed,
deleteSpeed,
delay = 0,
pauseDelay = 1000,
loop = false,
as: Component = "span",
startOnView = true,
showCursor = true,
blinkCursor = true,
cursorStyle = "line",
...props
}: TypingAnimationProps) {
const MotionComponent = motionElements[
Component
] as TypingAnimationMotionComponent
const [displayedText, setDisplayedText] = useState<string>("")
const [currentWordIndex, setCurrentWordIndex] = useState(0)
const [currentCharIndex, setCurrentCharIndex] = useState(0)
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
const elementRef = useRef<HTMLElement | null>(null)
const isInView = useInView(elementRef as RefObject<Element>, {
amount: 0.3,
once: true,
})
const wordsToAnimate = useMemo(
() => words ?? (children ? [children] : []),
[words, children]
)
const hasMultipleWords = wordsToAnimate.length > 1
const typingSpeed = typeSpeed ?? duration
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
const shouldStart = startOnView ? isInView : true
const animationSourceKey = useMemo(
() => (words ? words.join("\u0000") : (children ?? "")),
[words, children]
)
useEffect(() => {
setDisplayedText("")
setCurrentWordIndex(0)
setCurrentCharIndex(0)
setPhase("typing")
}, [animationSourceKey])
useEffect(() => {
let timeout: ReturnType<typeof setTimeout> | null = null
if (shouldStart && wordsToAnimate.length > 0) {
const timeoutDelay =
delay > 0 && displayedText === ""
? delay
: phase === "typing"
? typingSpeed
: phase === "deleting"
? deletingSpeed
: pauseDelay
timeout = setTimeout(() => {
const currentWord = wordsToAnimate[currentWordIndex] || ""
const graphemes = Array.from(currentWord)
switch (phase) {
case "typing":
if (currentCharIndex < graphemes.length) {
setDisplayedText(
graphemes.slice(0, currentCharIndex + 1).join("")
)
setCurrentCharIndex(currentCharIndex + 1)
} else {
if (hasMultipleWords || loop) {
const isLastWord =
currentWordIndex === wordsToAnimate.length - 1
if (!isLastWord || loop) {
setPhase("pause")
}
}
}
break
case "pause":
setPhase("deleting")
break
case "deleting":
if (currentCharIndex > 0) {
setDisplayedText(
graphemes.slice(0, currentCharIndex - 1).join("")
)
setCurrentCharIndex(currentCharIndex - 1)
} else {
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
setCurrentWordIndex(nextIndex)
setPhase("typing")
}
break
}
}, timeoutDelay)
}
return () => {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}, [
shouldStart,
phase,
currentCharIndex,
currentWordIndex,
displayedText,
wordsToAnimate,
hasMultipleWords,
loop,
typingSpeed,
deletingSpeed,
pauseDelay,
delay,
])
const currentWordGraphemes = Array.from(
wordsToAnimate[currentWordIndex] || ""
)
const isComplete =
!loop &&
currentWordIndex === wordsToAnimate.length - 1 &&
currentCharIndex >= currentWordGraphemes.length &&
phase !== "deleting"
const shouldShowCursor =
showCursor &&
!isComplete &&
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
const getCursorChar = () => {
switch (cursorStyle) {
case "block":
return "▌"
case "underscore":
return "_"
case "line":
default:
return "|"
}
}
return (
<MotionComponent
ref={elementRef}
className={cn(
"leading-20 tracking-[-0.02em]",
Component === "span" && "inline-block",
className
)}
{...props}
>
{displayedText}
{shouldShowCursor && (
<span
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
>
{getCursorChar()}
</span>
)}
</MotionComponent>
)
}
+150
View File
@@ -0,0 +1,150 @@
import React, { HTMLAttributes, useCallback, useMemo } from "react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
interface WarpBackgroundProps extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode
perspective?: number
beamsPerSide?: number
beamSize?: number
beamDelayMax?: number
beamDelayMin?: number
beamDuration?: number
gridColor?: string
}
const Beam = ({
width,
x,
delay,
duration,
}: {
width: string | number
x: string | number
delay: number
duration: number
}) => {
const hue = Math.floor(Math.random() * 360)
const ar = Math.floor(Math.random() * 10) + 1
return (
<motion.div
style={
{
"--x": `${x}`,
"--width": `${width}`,
"--aspect-ratio": `${ar}`,
"--background": `linear-gradient(hsl(${hue} 80% 60%), transparent)`,
} as React.CSSProperties
}
className={`absolute top-0 left-(--x) aspect-[1/var(--aspect-ratio)] w-(--width) [background:var(--background)]`}
initial={{ y: "100cqmax", x: "-50%" }}
animate={{ y: "-100%", x: "-50%" }}
transition={{
duration,
delay,
repeat: Infinity,
ease: "linear",
}}
/>
)
}
export const WarpBackground: React.FC<WarpBackgroundProps> = ({
children,
perspective = 100,
className,
beamsPerSide = 3,
beamSize = 5,
beamDelayMax = 3,
beamDelayMin = 0,
beamDuration = 3,
gridColor = "var(--border)",
...props
}) => {
const generateBeams = useCallback(() => {
const beams = []
const cellsPerSide = Math.floor(100 / beamSize)
const step = cellsPerSide / beamsPerSide
for (let i = 0; i < beamsPerSide; i++) {
const x = Math.floor(i * step)
const delay = Math.random() * (beamDelayMax - beamDelayMin) + beamDelayMin
beams.push({ x, delay })
}
return beams
}, [beamsPerSide, beamSize, beamDelayMax, beamDelayMin])
const topBeams = useMemo(() => generateBeams(), [generateBeams])
const rightBeams = useMemo(() => generateBeams(), [generateBeams])
const bottomBeams = useMemo(() => generateBeams(), [generateBeams])
const leftBeams = useMemo(() => generateBeams(), [generateBeams])
return (
<div className={cn("relative rounded border p-20", className)} {...props}>
<div
style={
{
"--perspective": `${perspective}px`,
"--grid-color": gridColor,
"--beam-size": `${beamSize}%`,
} as React.CSSProperties
}
className={
"@container-[size] pointer-events-none absolute top-0 left-0 size-full overflow-hidden [clipPath:inset(0)] perspective-(--perspective) transform-3d"
}
>
{/* top side */}
<div className="@container absolute z-20 h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{topBeams.map((beam, index) => (
<Beam
key={`top-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* bottom side */}
<div className="@container absolute top-full h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{bottomBeams.map((beam, index) => (
<Beam
key={`bottom-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* left side */}
<div className="@container absolute top-0 left-0 h-[100cqmax] w-[100cqh] origin-[0%_0%] transform-[rotate(90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{leftBeams.map((beam, index) => (
<Beam
key={`left-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
{/* right side */}
<div className="@container absolute top-0 right-0 h-[100cqmax] w-[100cqh] origin-[100%_0%] transform-[rotate(-90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
{rightBeams.map((beam, index) => (
<Beam
key={`right-${index}`}
width={`${beamSize}%`}
x={`${beam.x * beamSize}%`}
delay={beam.delay}
duration={beamDuration}
/>
))}
</div>
</div>
<div className="relative">{children}</div>
</div>
)
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Configuração de modelos de IA disponíveis
* Mapeia os nomes de exibição para os IDs usados pela API
*/
export interface ModelConfig {
name: string; // Nome exibido na interface
id: string; // ID enviado para a API
description?: string; // Descrição opcional do modelo
}
/**
* Interface para modelo retornado pela API do banco de dados
*/
export interface ModelIA {
id: number;
provider_id: number;
name: string;
model_identifier: string;
cost_input_per_million: string;
cost_output_per_million: string;
is_active: number;
created_at: string;
updated_at: string;
}
/**
* Lista de modelos disponíveis
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
* Futuramente será integrado com API para buscar dinamicamente.
*/
export const AVAILABLE_MODELS: ModelConfig[] = [
{
name: 'GPT-4.1',
id: '1',
description: 'Modelo GPT-4.1',
},
{
name: 'GPT-4o',
id: '2',
description: 'Modelo GPT-4o',
},
{
name: 'GPT-5 Mini',
id: '3',
description: 'Modelo GPT-5 Mini',
},
{
name: 'Gemini 2.0 Flash',
id: '4',
description: 'Google Gemini 2.0 Flash',
},
{
name: 'Claude Sonnet 4.5',
id: '5',
description: 'Anthropic Claude Sonnet 4.5',
},
{
name: 'DeepSeek V3.2 Chat',
id: '6',
description: 'DeepSeek V3.2 Chat',
},
{
name: 'DeepSeek V3.2 Reasoner',
id: '7',
description: 'DeepSeek V3.2 Reasoner',
},
{
name: 'Gemini 2.5 Flash',
id: '8',
description: 'Google Gemini 2.5 Flash',
},
{
name: 'Gemini 2.5 Flash-Lite',
id: '9',
description: 'Google Gemini 2.5 Flash-Lite',
},
{
name: 'Claude Haiku 4.5',
id: '10',
description: 'Anthropic Claude Haiku 4.5',
},
{
name: 'GPT-4o Mini',
id: '11',
description: 'Modelo GPT-4o Mini',
},
];
/**
* Mapeia o nome do modelo para o ID da API
*
* @param modelName - Nome do modelo exibido na interface
* @returns ID do modelo para a API, ou o próprio nome se não encontrado
*/
export function getModelId(modelName: string): string {
const model = AVAILABLE_MODELS.find(m => m.name === modelName);
return model?.id || modelName;
}
/**
* Mapeia o ID da API para o nome de exibição
*
* @param modelId - ID do modelo na API
* @returns Nome do modelo para exibir, ou o próprio ID se não encontrado
*/
export function getModelName(modelId: string): string {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
return model?.name || modelId;
}
/**
* Obtém a configuração completa de um modelo pelo nome
*
* @param modelName - Nome do modelo
* @returns Configuração do modelo ou undefined
*/
export function getModelConfig(modelName: string): ModelConfig | undefined {
return AVAILABLE_MODELS.find(m => m.name === modelName);
}
/**
* Retorna apenas os nomes dos modelos para uso em dropdowns
*/
export function getModelNames(): string[] {
return AVAILABLE_MODELS.map(m => m.name);
}
/**
* Busca modelos de IA disponíveis no banco de dados
*
* @returns Promise com array de modelos ativos
*/
export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
try {
// Importa dinamicamente para evitar circular dependency
const { apiService } = await import('@/services/api');
const response = await apiService.get<ModelIA[]>('/webhook/codex/get_models_ia');
console.log('Modelos carregados da API:', response.data);
// Filtra apenas modelos ativos e converte para ModelConfig
return response.data
.filter(model => model.is_active === 1)
.map((model: ModelIA) => ({
name: model.name,
id: model.id.toString(),
description: `${model.model_identifier}`,
}));
} catch (error) {
console.error('Erro ao buscar modelos da API:', error);
// Fallback para modelos hardcoded em caso de erro
console.warn('Usando modelos hardcoded como fallback');
return AVAILABLE_MODELS;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import type { MeData, PapelUsuario } from "@/services/fechamento/authMe";
export type AccessBlockReason = "nao_cadastrado" | "inativo" | "erro" | "bootstrap";
type AuthAccessState = {
loading: boolean;
isAuthenticated: boolean;
hasAccess: boolean;
papel: PapelUsuario | null;
me: MeData | null;
reason: AccessBlockReason | null;
errorMessage: string | null;
};
type AuthAccessContextValue = AuthAccessState & {
setAuthState: (state: Partial<AuthAccessState>) => void;
resetAuthState: () => void;
};
const initialState: AuthAccessState = {
loading: true,
isAuthenticated: false,
hasAccess: false,
papel: null,
me: null,
reason: null,
errorMessage: null,
};
const AuthAccessContext = createContext<AuthAccessContextValue | undefined>(undefined);
export function AuthAccessProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthAccessState>(initialState);
const setAuthState = useCallback((partial: Partial<AuthAccessState>) => {
setState((prev) => ({ ...prev, ...partial }));
}, []);
const resetAuthState = useCallback(() => {
setState(initialState);
}, []);
const value = useMemo<AuthAccessContextValue>(
() => ({
...state,
setAuthState,
resetAuthState,
}),
[state, setAuthState, resetAuthState],
);
return <AuthAccessContext.Provider value={value}>{children}</AuthAccessContext.Provider>;
}
export function useAuthAccess() {
const ctx = useContext(AuthAccessContext);
if (!ctx) {
throw new Error("useAuthAccess deve ser usado dentro de AuthAccessProvider.");
}
return ctx;
}
+91
View File
@@ -0,0 +1,91 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import { areasService, type AreaItem } from "@/services/areas";
import { toast } from "sonner";
export const DEFAULT_AREAS = [
"Bate-papo",
"Imagens",
"Transcrição de Áudio",
"Geração de Áudio",
"Agente de Parecer",
"Outro",
];
export interface Prompt {
id: string;
titulo: string;
area: string;
area_id?: string;
descricao?: string;
conteudo?: string;
/** ISO ou string da API (ex.: "2026-04-14 13:58:44"). */
created_at?: string;
}
export type AreaDescriptions = Record<string, string>;
export type AreaIdByNome = Record<string, string>;
interface PromptsContextValue {
prompts: Prompt[];
setPrompts: React.Dispatch<React.SetStateAction<Prompt[]>>;
areas: string[];
setAreas: React.Dispatch<React.SetStateAction<string[]>>;
areaDescriptions: AreaDescriptions;
setAreaDescriptions: React.Dispatch<React.SetStateAction<AreaDescriptions>>;
areaItems: AreaItem[];
areaIdByNome: AreaIdByNome;
areasLoading: boolean;
areasError: string | null;
refreshAreas: () => Promise<void>;
}
const PromptsContext = createContext<PromptsContextValue | null>(null);
export function PromptsProvider({ children }: { children: React.ReactNode }) {
const [prompts, setPrompts] = useState<Prompt[]>([]);
const [areas, setAreas] = useState<string[]>(() => [...DEFAULT_AREAS]);
const [areaDescriptions, setAreaDescriptions] = useState<AreaDescriptions>({});
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
const [areaIdByNome, setAreaIdByNome] = useState<AreaIdByNome>({});
const [areasLoading, setAreasLoading] = useState(true);
const [areasError, setAreasError] = useState<string | null>(null);
const refreshAreas = useCallback(async () => {
setAreasLoading(true);
setAreasError(null);
try {
const data = await areasService.listarTotal();
const nomes = data.map((a) => a.nome).sort((a, b) => a.localeCompare(b));
const descricoes = Object.fromEntries(data.map((a) => [a.nome, a.descricao ?? ""]));
const byNome = Object.fromEntries(data.map((a) => [a.nome, a.id]));
setAreas(nomes.length > 0 ? nomes : [...DEFAULT_AREAS]);
setAreaDescriptions(descricoes);
setAreaItems(data);
setAreaIdByNome(byNome);
} catch (err) {
const message = (err as { message?: string })?.message ?? "Erro ao carregar áreas";
console.error("[PromptsContext] areasService.listarTotal falhou:", err);
setAreasError(message);
toast.error("Não foi possível carregar as áreas. Verifique a conexão e a chave da API.");
} finally {
setAreasLoading(false);
}
}, []);
useEffect(() => {
refreshAreas();
}, [refreshAreas]);
return (
<PromptsContext.Provider value={{ prompts, setPrompts, areas, setAreas, areaDescriptions, setAreaDescriptions, areaItems, areaIdByNome, areasLoading, areasError, refreshAreas }}>
{children}
</PromptsContext.Provider>
);
}
export function usePrompts() {
const ctx = useContext(PromptsContext);
if (!ctx) throw new Error("usePrompts must be used within PromptsProvider");
return ctx;
}
+345 -67
View File
@@ -6,103 +6,220 @@
@layer base { @layer base {
:root { :root {
/* HGTX Codex - Light Theme */ /* ─── HGTX Commander — Design System Tokens (compartilhados) ─── */
--background: 210 40% 98%;
--foreground: 222 47% 11%; /* Tipografia */
--fs-display: 32px;
--fs-h1: 24px;
--fs-h2: 18px;
--fs-h3: 15px;
--fs-body: 14px;
--fs-small: 12px;
--fs-caption: 11px;
--lh-tight: 1.25;
--lh-body: 1.5;
--lh-relaxed: 1.65;
--fw-regular: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
/* Espaçamento */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 48px;
--space-9: 64px;
--section-y: 64px;
--section-x: 40px;
--nav-h: 56px;
--container: 1200px;
/* Border radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 999px;
/* ─── Light Theme ─── */
--background: 0 0% 100%;
--foreground: 209 11% 15%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222 47% 11%; --card-foreground: 209 11% 15%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 47% 11%; --popover-foreground: 209 11% 15%;
--primary: 190 100% 45%; /* Azul primário vivo (referência ~#004080 / #0055a4) */
--primary: 210 100% 33%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-hover: 210 100% 28%;
--primary-pressed: 210 100% 24%;
--primary-light: 210 55% 96%;
--secondary: 210 100% 45%; /* Secondary neutro para botões */
--secondary-foreground: 0 0% 100%; --secondary: 210 6% 60%;
--secondary-foreground: 209 11% 15%;
--muted: 210 40% 96%; /* Muted — bg suave */
--muted-foreground: 215 16% 47%; --muted: 210 17% 96%;
--muted-foreground: 209 7% 46%;
--accent: 190 100% 50%; /* Accent = fundo nav ativo */
--accent-foreground: 0 0% 100%; --accent: 210 70% 94%;
--accent-foreground: 210 100% 33%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 214 32% 91%; /* Sucesso: #28a745 */
--input: 214 32% 91%; --success: 134 61% 41%;
--ring: 190 100% 45%; --success-foreground: 0 0% 100%;
--radius: 0.75rem; --warning: 38 92% 50%;
--warning-foreground: 209 11% 15%;
/* Sidebar specific - Light */ /* Borda: #e5e7eb */
--sidebar-background: 210 40% 99%; --border: 220 13% 91%;
--sidebar-foreground: 222 47% 11%; /* --input no shadcn = cor da BORDA de Input/Select/Textarea */
--sidebar-primary: 190 100% 45%; --input: 220 9% 83%;
--ring: 210 100% 33%;
--radius: 0.5rem;
/* Sidebar — Light */
--sidebar-background: 210 17% 98%;
--sidebar-foreground: 209 11% 15%;
--sidebar-primary: 210 100% 33%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 95%; --sidebar-accent: 210 70% 94%;
--sidebar-accent-foreground: 222 47% 11%; --sidebar-accent-foreground: 210 100% 33%;
--sidebar-border: 214 32% 88%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 190 100% 45%; --sidebar-ring: 210 100% 33%;
/* Custom gradients - Light */ /* Tokens extras Light */
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 45%) 0%, hsl(210 100% 45%) 100%); --bubble-bot: 209 36% 96%;
--gradient-subtle: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(210 40% 95%) 100%); --nav-active: 210 70% 94%;
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.15); --input-bg: 0 0% 100%;
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.12); --placeholder: 220 9% 66%;
--btn-secondary-border: 220 9% 83%;
--border-subtle: 220 13% 95%;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-modal: 0 16px 40px rgba(0, 0, 0, 0.12);
--focus-ring: rgba(0, 85, 164, 0.28);
/* Gradientes — Light */
--gradient-cyber: linear-gradient(135deg, hsl(210 100% 33%) 0%, hsl(210 90% 42%) 100%);
--gradient-subtle: linear-gradient(180deg, hsl(0 0% 100%) 0%, hsl(210 17% 98%) 100%);
--glow-cyan: 0 0 40px hsl(210 100% 33% / 0.15);
--glow-blue: 0 0 30px hsl(210 90% 42% / 0.12);
/* Animation speeds */
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
} }
.dark { .dark {
/* HGTX Codex - Dark Theme */ /* ─── Dark Theme ─── */
--background: 222 47% 5%; /* Fundo principal = mesma família da sidebar (216 50% 9%), levemente mais profundo */
--foreground: 200 100% 95%; --background: 216 48% 8%;
--foreground: 0 0% 100%;
--card: 222 40% 8%; /* Cards/painéis um tom acima do fundo para hierarquia sutil */
--card-foreground: 200 100% 95%; --card: 216 42% 11%;
--card-foreground: 0 0% 100%;
--popover: 222 45% 10%; --popover: 216 42% 11%;
--popover-foreground: 200 100% 95%; --popover-foreground: 0 0% 100%;
--primary: 190 100% 50%; /* Azul primário dark — vibrante em fundo escuro */
--primary-foreground: 222 47% 5%; --primary: 210 90% 52%;
--primary-foreground: 0 0% 100%;
--primary-hover: 210 95% 46%;
--primary-pressed: 210 100% 40%;
--primary-light: 210 50% 14%;
--secondary: 210 100% 50%; --secondary: 216 38% 13%;
--secondary-foreground: 222 47% 5%; --secondary-foreground: 0 0% 100%;
--muted: 222 30% 15%; --muted: 216 38% 13%;
--muted-foreground: 200 20% 65%; --muted-foreground: 212 9% 58%;
--accent: 190 100% 60%; /* Accent dark */
--accent-foreground: 222 47% 5%; --accent: 210 45% 16%;
--accent-foreground: 210 85% 62%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 200 100% 95%; --destructive-foreground: 0 0% 100%;
--border: 222 30% 18%; /* Sucesso dark: #23d160 */
--input: 222 30% 18%; --success: 141 71% 48%;
--ring: 190 100% 50%; --success-foreground: 216 48% 8%;
/* Sidebar specific - Dark */ --warning: 38 92% 50%;
--sidebar-background: 222 45% 7%; --warning-foreground: 216 48% 8%;
--sidebar-foreground: 200 100% 90%;
--sidebar-primary: 190 100% 50%;
--sidebar-primary-foreground: 222 47% 5%;
--sidebar-accent: 222 35% 12%;
--sidebar-accent-foreground: 190 100% 60%;
--sidebar-border: 222 30% 15%;
--sidebar-ring: 190 100% 50%;
/* Custom gradients - Dark */ /* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 50%) 0%, hsl(210 100% 50%) 100%); --border: 216 18% 26%;
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%); /* --input no shadcn = cor da BORDA de Input/Select/Textarea (não o fundo) */
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3); --input: 216 16% 30%;
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25); --ring: 210 90% 52%;
/* Sidebar — Dark */
--sidebar-background: 216 50% 9%;
--sidebar-foreground: 0 0% 100%;
--sidebar-primary: 210 90% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 45% 16%;
--sidebar-accent-foreground: 210 85% 62%;
--sidebar-border: 212 12% 21%;
--sidebar-ring: 210 90% 52%;
/* Tokens extras — Dark */
--bubble-bot: 216 36% 14%;
--nav-active: 210 45% 16%;
--input-bg: 216 40% 10%;
--placeholder: 212 10% 31%;
--btn-secondary-border: 216 16% 30%;
--border-subtle: 216 22% 20%;
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.55);
--focus-ring: rgba(51, 136, 255, 0.35);
/* Gradientes — Dark */
--gradient-cyber: linear-gradient(135deg, hsl(210 90% 52%) 0%, hsl(210 100% 40%) 100%);
--gradient-subtle: linear-gradient(180deg, hsl(216 42% 11%) 0%, hsl(216 48% 8%) 100%);
--glow-cyan: 0 0 40px hsl(210 90% 52% / 0.35);
--glow-blue: 0 0 30px hsl(210 100% 40% / 0.28);
--color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133);
}
.theme {
--animate-shiny-text: shiny-text 8s infinite;
--animate-gradient: gradient 8s linear infinite;
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
--animate-shimmer-slide: shimmer-slide var(--speed) ease-in-out infinite alternate;
--animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear;
--animate-rippling: rippling var(--duration) ease-out;
--animate-blink-cursor: blink-cursor 1.2s step-end infinite;
--animate-background-position-spin: background-position-spin 3000ms infinite alternate;
--animate-ripple: ripple var(--duration,2s) ease calc(var(--i, 0)*.2s) infinite;
} }
} }
@@ -111,12 +228,21 @@
@apply border-border transition-colors; @apply border-border transition-colors;
} }
html,
body,
#root {
height: 100%;
}
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
} }
/* Scrollbar styling */ #root {
@apply min-h-0;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -137,7 +263,7 @@
@layer components { @layer components {
.glass-effect { .glass-effect {
@apply bg-card/40 backdrop-blur-xl border border-border/50; @apply bg-card/80 backdrop-blur-xl border border-border/50;
} }
.cyber-glow { .cyber-glow {
@@ -146,10 +272,162 @@
.cyber-border { .cyber-border {
@apply border border-primary/30 rounded-lg; @apply border border-primary/30 rounded-lg;
box-shadow: 0 0 20px hsl(190 100% 50% / 0.1); box-shadow: 0 0 20px hsl(210 100% 33% / 0.1);
} }
.gradient-text { .gradient-text {
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent; @apply bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent;
}
.animated-gradient-text {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.metric-card {
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
box-shadow: var(--shadow-card);
}
.nav-item {
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full;
display: flex;
align-items: center;
text-decoration: none;
}
.nav-item svg,
.nav-item .w-5 {
flex-shrink: 0;
}
.nav-item span {
white-space: nowrap;
}
.nav-item-inactive {
@apply text-muted-foreground hover:text-foreground hover:bg-sidebar-accent;
}
.nav-item-active {
@apply text-primary bg-sidebar-accent font-medium;
}
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-connected {
@apply bg-success;
box-shadow: 0 0 8px hsl(var(--success) / 0.6);
animation: pulse 2s infinite;
}
.status-error {
@apply bg-destructive;
box-shadow: 0 0 8px hsl(var(--destructive) / 0.6);
animation: pulse 2s infinite;
}
.status-warning {
@apply bg-warning;
box-shadow: 0 0 8px hsl(var(--warning) / 0.6);
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes gradient-text-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@theme inline {
@keyframes shiny-text {
0%, 90%, 100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%, 60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
@keyframes gradient {
to {
background-position: var(--bg-size, 300%) 0;
}
}
@keyframes rainbow {
0% {
background-position: 0%;
}
100% {
background-position: 200%;
}
}
@keyframes shimmer-slide {
to {
transform: translate(calc(100cqw - 100%), 0);
}
}
@keyframes spin-around {
0% {
transform: translateZ(0) rotate(0);
}
15%, 35% {
transform: translateZ(0) rotate(90deg);
}
65%, 85% {
transform: translateZ(0) rotate(270deg);
}
100% {
transform: translateZ(0) rotate(360deg);
}
}
@keyframes rippling {
0% {
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
@keyframes blink-cursor {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes background-position-spin {
0% {
background-position: top center;
}
100% {
background-position: bottom center;
}
}
@keyframes ripple {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
} }
} }
+49
View File
@@ -0,0 +1,49 @@
import { Navigate, Route, Routes } from "react-router-dom";
import React from "react";
import { GlobalFunctions } from "@/GlobalFunctions";
import { PromptsProvider } from "@/contexts/PromptsContext";
import { MainLayout } from "./components/layout/MainLayout";
import { ChatView } from "@/components/chat/ChatView";
import { ImageView } from "@/components/images/ImageView";
import { TranscriptionView } from "@/components/audio/TranscriptionView";
import { GenerationView } from "@/components/audio/GenerationView";
import { PromptsView } from "@/components/prompts/PromptsView";
import { PromptsFormView } from "@/components/prompts/PromptsFormView";
import { AreasView } from "@/components/areas/AreasView";
import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
import { CommanderCsvView } from "@/components/commander-csv/CommanderCsvView";
const CommanderApp = () => {
React.useEffect(() => {
if (!GlobalFunctions.isUsuarioLogado()) {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
}
}, []);
return (
<PromptsProvider>
<MainLayout>
<Routes>
<Route index element={<Navigate to="bate-papo" replace />} />
<Route path="bate-papo" element={<ChatView />} />
<Route path="imagens" element={<ImageView />} />
<Route path="transcricao-audio" element={<TranscriptionView />} />
<Route path="geracao-audio" element={<GenerationView />} />
<Route path="prompts" element={<PromptsView />} />
<Route path="prompts/novo" element={<PromptsFormView />} />
<Route path="prompts/:id" element={<PromptsFormView />} />
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
<Route path="areas" element={<AreasView />} />
<Route path="commander-csv" element={<CommanderCsvView />} />
<Route path="*" element={<Navigate to="bate-papo" replace />} />
</Routes>
</MainLayout>
</PromptsProvider>
);
};
export default CommanderApp;
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
interface CommanderLogoProps {
className?: string;
/** Classes do container (expandido: proporção 3:2 da arte; recolhido: quadrado) */
heightClass?: string;
}
export const CommanderLogo = ({
className,
heightClass = "h-36 w-full",
}: CommanderLogoProps) => {
const imgClass = cn(
"absolute inset-0 h-full w-full object-contain object-left",
className
);
return (
<div className={cn("relative w-full shrink-0", heightClass)}>
<img
src="/hgtx-commander-light.png"
alt="HGTX Commander"
className={cn(imgClass, "dark:hidden")}
/>
<img
src="/hgtx-commander-dark.png"
alt="HGTX Commander"
className={cn(imgClass, "hidden dark:block")}
/>
</div>
);
};
@@ -0,0 +1,217 @@
import { useState, type ComponentType } from "react";
import { NavLink } from "react-router-dom";
import {
MessageSquare,
Image,
AudioLines,
Mic,
ArrowLeft,
ChevronLeft,
ChevronRight,
Bot,
FileText,
Layers,
FileSpreadsheet,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalFunctions } from "@/GlobalFunctions";
import { CommanderLogo } from "./CommanderLogo";
import { cn } from "@/lib/utils";
type NavItem = {
path: string;
label: string;
icon: ComponentType<{ className?: string }>;
};
const navSections: { title: string; items: NavItem[] }[] = [
{
title: "I.A",
items: [
{ path: "bate-papo", label: "Bate-papo", icon: MessageSquare },
{ path: "imagens", label: "Imagens", icon: Image },
{ path: "transcricao-audio", label: "Transcrição de Áudio", icon: AudioLines },
{ path: "geracao-audio", label: "Geração de Áudio", icon: Mic },
],
},
{
title: "Parecer Jurídico",
items: [
{ path: "areas", label: "Áreas", icon: Layers },
{ path: "parecer-juridico", label: "Parecer Jurídico", icon: Bot },
{ path: "prompts", label: "Prompts", icon: FileText },
],
},
{
title: "Personalizado",
items: [
{ path: "commander-csv", label: "Commander CSV", icon: FileSpreadsheet },
],
},
];
const navItems = navSections.flatMap((section) => section.items);
const navLinkClass = (isActive: boolean, collapsed?: boolean) =>
cn(
"flex items-center rounded-lg text-sm font-medium transition-all duration-200 w-full",
collapsed
? "justify-center p-3"
: "gap-3 px-3 py-2.5",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground"
);
interface MainLayoutProps {
children: React.ReactNode;
}
export const MainLayout = ({ children }: MainLayoutProps) => {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex h-screen w-full bg-background overflow-hidden">
{/* Sidebar desktop expandido */}
{!collapsed ? (
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
{/* Logo + recolher lado a lado */}
<div className="flex items-center gap-2 border-b border-sidebar-border px-4 py-3">
<div className="min-w-0 flex-1">
<CommanderLogo />
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(true)}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
title="Recolher menu"
>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
{/* Navegação */}
<nav className="flex-1 space-y-3 overflow-y-auto px-3 py-4">
{navSections.map((section) => (
<div key={section.title} className="space-y-1">
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{section.title}
</p>
{section.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) => navLinkClass(isActive)}
>
<item.icon className="h-5 w-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
))}
</div>
))}
</nav>
<Separator className="bg-sidebar-border" />
{/* Rodapé */}
<div className="p-4 border-t border-sidebar-border space-y-2">
<div className="flex items-center justify-between px-3">
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
<Button
onClick={GlobalFunctions.handleNavigateBack}
variant="ghost"
className="w-full justify-start gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</div>
</aside>
) : (
/* Sidebar desktop recolhido */
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
<div className="p-3 border-b border-sidebar-border space-y-2">
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(false)}
className="w-full text-muted-foreground hover:text-foreground"
title="Expandir"
>
<ChevronRight className="w-4 h-4" />
</Button>
<div className="flex justify-center">
<CommanderLogo heightClass="h-8 w-8" />
</div>
</div>
<nav className="flex-1 px-2 py-2 space-y-0.5">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) => navLinkClass(isActive, true)}
title={item.label}
>
<item.icon className="w-5 h-5 shrink-0" />
</NavLink>
))}
</nav>
<Separator className="bg-sidebar-border" />
<div className="p-3 border-t border-sidebar-border space-y-2">
<div className="flex justify-center">
<ThemeToggle />
</div>
<Button
variant="ghost"
size="icon"
className="w-full text-sidebar-foreground hover:bg-sidebar-accent/60"
title="Voltar"
onClick={GlobalFunctions.handleNavigateBack}
>
<ArrowLeft className="w-4 h-4" />
</Button>
</div>
</aside>
)}
{/* Conteúdo principal */}
<main className="flex flex-1 flex-col min-h-0 overflow-hidden w-full">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
</main>
{/* Navegação mobile (bottom bar) */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) =>
cn(
"flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all",
isActive ? "text-primary" : "text-sidebar-foreground/70"
)
}
>
{({ isActive }) => (
<>
<item.icon className={cn("w-5 h-5", isActive && "text-primary")} />
<span className="text-xs font-medium">{item.label}</span>
</>
)}
</NavLink>
))}
</nav>
</div>
);
};
+26
View File
@@ -0,0 +1,26 @@
import { Routes, Route } from "react-router-dom";
import { MainLayout } from "@/modules/intelligence-ia/components/layout/MainLayout";
import Index from "./pages/Index";
import Integrations from "./pages/Integrations";
import Financas from "./pages/Financas";
import MeuPerfil from "./pages/MeuPerfil";
import NotFound from "./pages/NotFound";
// App do módulo Intelligence IA - funciona como subpath
// Não precisa de BrowserRouter, QueryClientProvider, TooltipProvider, Toaster ou Sonner
// pois já são fornecidos pelo App principal
const IntelligenceIAApp = () => {
return (
<MainLayout>
<Routes>
<Route index element={<Index />} />
<Route path="integracoes" element={<Integrations />} />
<Route path="financas" element={<Financas />} />
<Route path="meu-perfil" element={<MeuPerfil />} />
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
);
};
export default IntelligenceIAApp;
Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };
@@ -0,0 +1,44 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: ReactNode;
status?: "success" | "warning" | "error";
className?: string;
}
export function MetricCard({
title,
value,
subtitle,
icon,
status,
className,
}: MetricCardProps) {
return (
<div className={cn("metric-card animate-fade-in", className)}>
<div className="flex items-start justify-between mb-3">
<div className="p-2.5 rounded-lg bg-primary/10 text-primary">
{icon}
</div>
{status && (
<span
className={cn("status-dot", {
"status-connected": status === "success",
"status-warning": status === "warning",
"status-error": status === "error",
})}
/>
)}
</div>
<p className="text-muted-foreground text-sm mb-1">{title}</p>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{subtitle && (
<p className="text-muted-foreground text-xs mt-1">{subtitle}</p>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
interface StatusBadgeProps {
status: "connected" | "error" | "disconnected" | "pending";
label?: string;
}
const statusConfig = {
connected: {
label: "Conectado",
className: "bg-success/10 text-success border-success/20",
},
error: {
label: "Erro",
className: "bg-destructive/10 text-destructive border-destructive/20",
},
disconnected: {
label: "Desconectado",
className: "bg-muted text-muted-foreground border-border",
},
pending: {
label: "Pendente",
className: "bg-warning/10 text-warning border-warning/20",
},
};
export function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
config.className
)}
>
<span
className={cn("w-1.5 h-1.5 rounded-full", {
"bg-success": status === "connected",
"bg-destructive": status === "error",
"bg-muted-foreground": status === "disconnected",
"bg-warning": status === "pending",
})}
/>
{label || config.label}
</span>
);
}
@@ -0,0 +1,291 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import asanaLogo from "@/modules/intelligence-ia/assets/asana-logo.png";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
interface AsanaCardProps {
apiKey?: string;
workspaces?: Workspace[];
users?: User[];
selectedWorkspaceId?: string;
selectedUserId?: string;
loadingWorkspaces?: boolean;
loadingUsers?: boolean;
loadingIntegration?: boolean;
hasIntegration?: boolean;
saving?: boolean;
onSave?: (apiKey: string, workspaceId: string, userId: string) => void | Promise<void>;
onConfirmApiKey?: (apiKey: string) => void | Promise<void>;
onWorkspaceChange?: (workspaceId: string) => void | Promise<void>;
}
export function AsanaCard({
apiKey = "",
workspaces = [],
users = [],
selectedWorkspaceId = "",
selectedUserId = "",
loadingWorkspaces = false,
loadingUsers = false,
loadingIntegration = false,
hasIntegration = false,
saving = false,
onSave,
onConfirmApiKey,
onWorkspaceChange,
}: AsanaCardProps) {
const [showKey, setShowKey] = useState(false);
const [key, setKey] = useState(apiKey);
const [selectedWorkspace, setSelectedWorkspace] = useState(selectedWorkspaceId);
const [selectedUser, setSelectedUser] = useState(selectedUserId);
const [isApiKeyConfirmed, setIsApiKeyConfirmed] = useState(!!apiKey);
// Atualiza os valores quando as props mudam (carregamento inicial)
useEffect(() => {
if (apiKey) {
setKey(apiKey);
setIsApiKeyConfirmed(true);
}
}, [apiKey]);
useEffect(() => {
if (selectedWorkspaceId) {
setSelectedWorkspace(selectedWorkspaceId);
}
}, [selectedWorkspaceId]);
useEffect(() => {
if (selectedUserId) {
setSelectedUser(selectedUserId);
}
}, [selectedUserId]);
const handleKeyChange = (value: string) => {
setKey(value);
setSelectedWorkspace("");
setSelectedUser("");
setIsApiKeyConfirmed(false);
};
const handleConfirmApiKey = async () => {
if (key.length >= 5) {
setIsApiKeyConfirmed(true);
// Chama a função assíncrona para buscar workspaces
await onConfirmApiKey?.(key);
}
};
const handleWorkspaceChange = async (value: string) => {
setSelectedWorkspace(value);
setSelectedUser("");
// Chama a função assíncrona para buscar usuários
await onWorkspaceChange?.(value);
};
const handleSave = async () => {
await onSave?.(key, selectedWorkspace, selectedUser);
};
return (
<div className="metric-card space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={asanaLogo} alt="Asana" className="w-6 h-6" />
</div>
<div>
<h3 className="text-foreground font-medium">Asana</h3>
<p className="text-muted-foreground text-sm">Gerencie tarefas e projetos</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
API Key / Token
</label>
<div className="relative">
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</div>
) : (
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? "text" : "password"}
value={key}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="Insira sua chave de API"
className="pr-10 bg-card border-border"
disabled={loadingWorkspaces}
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={loadingWorkspaces}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<Button
size="sm"
variant="outline"
onClick={handleConfirmApiKey}
disabled={key.length < 5 || isApiKeyConfirmed || loadingWorkspaces}
className="shrink-0"
>
{loadingWorkspaces ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Carregando...
</>
) : isApiKeyConfirmed ? (
"Confirmado"
) : (
"Confirmar"
)}
</Button>
</div>
)}
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Workspace
</label>
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando integração...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedWorkspace}
onValueChange={handleWorkspaceChange}
disabled={loadingWorkspaces || loadingIntegration}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
loadingWorkspaces
? "Carregando workspaces..."
: workspaces.length === 0
? "Insira o token e clique em Confirmar"
: "Selecione um workspace"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{workspaces.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingWorkspaces && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Usuário
</label>
{loadingUsers ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando usuários...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedUser}
onValueChange={setSelectedUser}
disabled={loadingUsers || !selectedWorkspace}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
!selectedWorkspace
? "Selecione um workspace primeiro"
: users.length === 0
? "Nenhum usuário encontrado"
: "Selecione um usuário"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
onClick={handleSave}
disabled={!selectedWorkspace || !selectedUser || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{hasIntegration ? "Salvando..." : "Criando..."}
</>
) : hasIntegration ? (
"Salvar"
) : (
"Criar Integração"
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import googleCalendarLogo from "@/modules/intelligence-ia/assets/google-calendar-logo.png";
export function GoogleCalendarCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={googleCalendarLogo} alt="Google Calendar" className="w-6 h-6 grayscale" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Calendar</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Sincronize reuniões e eventos</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Nome da Agenda
</label>
<Input
type="text"
disabled
placeholder="Ex: Reuniões de Trabalho"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { FileSpreadsheet } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function GoogleSheetsCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<FileSpreadsheet className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Sheets</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Gerencie planilhas e dados</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Link da Planilha
</label>
<Input
type="text"
disabled
placeholder="Cole o link da planilha aqui"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Plug,
Bot,
ChevronLeft,
Menu,
X,
DollarSign,
UserCircle,
} from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const navItems = [
{ title: "Home", path: "", icon: LayoutDashboard },
{ title: "Finanças", path: "financas", icon: DollarSign },
{ title: "Integrações", path: "integracoes", icon: Plug },
{ title: "Meu Perfil", path: "meu-perfil", icon: UserCircle },
];
export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const SidebarContent = () => (
<>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<Bot className="w-5 h-5 text-primary" />
</div>
{!collapsed && (
<div className="animate-fade-in">
<h1 className="text-sidebar-foreground font-bold text-h2">AI Agent</h1>
<p className="text-muted-foreground text-xs">Painel Admin</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === ""} // Apenas a Home precisa de match exato
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full",
isActive
? "text-primary bg-sidebar-accent font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
)
}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</nav>
<div
className={cn(
"mt-auto space-y-2 border-t border-sidebar-border",
collapsed ? "p-3" : "p-4",
)}
>
<div
className={cn(
"flex items-center justify-between px-3",
collapsed && "lg:hidden",
)}
>
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
{collapsed && (
<div className="hidden justify-center lg:flex">
<ThemeToggle />
</div>
)}
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="nav-item hidden w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent/60 hover:text-foreground lg:flex lg:justify-start"
>
<ChevronLeft
className={cn(
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
collapsed && "rotate-180",
)}
/>
{!collapsed && <span>Recolher</span>}
</button>
</div>
</>
);
return (
<>
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border shadow-sm"
>
<Menu className="w-5 h-5" />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={cn(
"lg:hidden fixed left-0 top-0 h-full w-64 bg-sidebar z-50 flex flex-col border-r border-sidebar-border transition-transform duration-300",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-4 right-4 p-2 rounded-lg hover:bg-sidebar-accent"
>
<X className="w-5 h-5" />
</button>
<SidebarContent />
</aside>
{/* Desktop sidebar */}
<aside
className={cn(
"hidden lg:flex flex-col h-screen bg-sidebar border-r border-sidebar-border transition-all duration-300 sticky top-0",
collapsed ? "w-[72px]" : "w-64"
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -0,0 +1,19 @@
import { ReactNode } from "react";
import { AppSidebar } from "./AppSidebar";
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">
{children}
</div>
</main>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Bot, Sparkles, Zap, Brain, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { GlobalFunctions } from "@/GlobalFunctions";
export default function Dashboard() {
const today = new Date();
const formattedDate = format(today, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR });
const hour = today.getHours();
const getGreeting = () => {
if (hour < 12) return "Bom dia";
if (hour < 18) return "Boa tarde";
return "Boa noite";
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
{/* Floating particles background effect */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/30 rounded-full animate-pulse" />
<div className="absolute top-1/3 right-1/3 w-1 h-1 bg-primary/40 rounded-full animate-pulse delay-300" />
<div className="absolute bottom-1/3 left-1/3 w-1.5 h-1.5 bg-primary/20 rounded-full animate-pulse delay-500" />
<div className="absolute top-1/2 right-1/4 w-1 h-1 bg-primary/30 rounded-full animate-pulse delay-700" />
</div>
{/* AI Agent Avatar */}
<div className="relative mb-8 animate-fade-in">
{/* Outer glow ring */}
<div className="absolute inset-0 w-32 h-32 rounded-full bg-gradient-to-r from-primary/20 via-primary/10 to-primary/20 blur-xl animate-pulse" />
{/* Middle ring */}
<div className="relative w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 p-1 cyber-glow">
<div className="w-full h-full rounded-full bg-card border border-primary/30 flex items-center justify-center relative overflow-hidden">
{/* Inner gradient effect */}
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 to-transparent" />
{/* Bot icon */}
<Bot className="w-14 h-14 text-primary relative z-10" />
{/* Scanning line effect */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/20 to-transparent h-8 animate-[slide-scan_2s_ease-in-out_infinite]" />
</div>
</div>
{/* Orbiting icons */}
<div className="absolute -top-2 -right-2 w-10 h-10 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-bounce">
<Sparkles className="w-5 h-5 text-yellow-500" />
</div>
<div className="absolute -bottom-1 -left-1 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse">
<Zap className="w-4 h-4 text-primary" />
</div>
<div className="absolute top-1/2 -right-4 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse delay-300">
<Brain className="w-4 h-4 text-primary" />
</div>
</div>
{/* Greeting */}
<div className="space-y-3 animate-fade-in" style={{ animationDelay: "0.2s" }}>
<h1 className="text-4xl md:text-5xl font-bold text-foreground">
{getGreeting()}! 👋
</h1>
<p className="text-xl text-muted-foreground">
Seu agente de IA está pronto para ajudar
</p>
</div>
{/* Date card */}
<div
className="mt-8 px-6 py-3 rounded-full bg-card/50 border border-border backdrop-blur-sm animate-fade-in"
style={{ animationDelay: "0.4s" }}
>
<p className="text-muted-foreground capitalize">
📅 {formattedDate}
</p>
</div>
{/* Status indicator */}
<div
className="mt-6 flex items-center gap-2 text-sm animate-fade-in"
style={{ animationDelay: "0.6s" }}
>
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-muted-foreground">Sistema operacional</span>
</div>
{/* WhatsApp Button */}
<Button
asChild
size="lg"
variant="outline"
className="mt-8 gap-2 border-[#25D366] text-[#25D366] hover:bg-[#25D366] hover:text-white hover:border-[#25D366] animate-fade-in"
style={{ animationDelay: "0.8s" }}
>
<a href="https://wa.me/5511914382960" target="_blank" rel="noopener noreferrer">
<MessageCircle className="w-5 h-5" />
Conversar com o Agente
</a>
</Button>
</div>
);
}
@@ -0,0 +1,777 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DollarSign,
TrendingUp,
TrendingDown,
Building2,
User,
Receipt,
Filter,
Calendar,
Search,
CalendarIcon,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Badge } from "@/components/ui/badge";
import { personalAgent, FinancialIndicators, ExpenseItem, ExpensesResponse, ExpensesFilters, ExpenseCategory } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { toast } from "@/hooks/use-toast";
// Mapeamento de categoria_id para cores (cores padrão caso não tenha categoria)
const categoryColorMap: Record<number, string> = {
1: "bg-orange-500/10 text-orange-600 border-orange-500/20", // Alimentação
2: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20", // Combustível
3: "bg-purple-500/10 text-purple-600 border-purple-500/20", // Hospedagem
4: "bg-blue-500/10 text-blue-600 border-blue-500/20", // Estacionamento
5: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20", // Pedágio
6: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20", // Transporte
};
// Função auxiliar para obter informações da categoria
const getCategoryInfo = (categoriaId: number | undefined | null, categories: ExpenseCategory[]) => {
// Se não tem categoria_id válido, retorna padrão
if (categoriaId === undefined || categoriaId === null || categoriaId === 0) {
return {
label: "Outros",
color: "bg-gray-500/10 text-gray-600 border-gray-500/20",
};
}
// Converte para número caso venha como string
const id = typeof categoriaId === 'string' ? parseInt(categoriaId, 10) : categoriaId;
// Busca a categoria na lista carregada
const category = categories.find(cat => cat.id === id);
// Se encontrou a categoria, usa o nome dela
if (category) {
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: category.nome,
color: color,
};
}
// Se não encontrou mas tem ID válido, usa o ID com cor padrão
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: `Categoria ${id}`,
color: color,
};
};
function formatCurrency(value: number) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
}
function formatDateTime(dateTimeString: string) {
// Formata "2026-01-14 10:38:57" para Date
const date = new Date(dateTimeString.replace(" ", "T"));
return {
date: date.toLocaleDateString("pt-BR"),
time: date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }),
};
}
export default function Financas() {
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all"); // Agora será o ID da categoria ou "all"
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Estados para os indicadores financeiros da API
const [loadingIndicators, setLoadingIndicators] = useState(true);
const [indicators, setIndicators] = useState<FinancialIndicators | null>(null);
// Estados para as despesas da API
const [loadingExpenses, setLoadingExpenses] = useState(true);
const [expensesData, setExpensesData] = useState<ExpensesResponse | null>(null);
const [userEmail, setUserEmail] = useState<string>("");
// Estados para as categorias da API
const [loadingCategories, setLoadingCategories] = useState(true);
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
// Carrega os indicadores financeiros da API
useEffect(() => {
const loadFinancialIndicators = async () => {
try {
setLoadingIndicators(true);
// Obtém o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
console.error("Financas: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoadingIndicators(false);
return;
}
// Busca os indicadores financeiros da API
const response = await personalAgent.getFinancialIndicators(userEmail);
if (response.success) {
setIndicators(response);
} else {
throw new Error(response.message || "Erro ao carregar indicadores financeiros");
}
} catch (error: unknown) {
console.error("Erro ao carregar indicadores financeiros:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar indicadores financeiros.",
variant: "destructive",
});
} finally {
setLoadingIndicators(false);
}
};
loadFinancialIndicators();
}, []);
// Função auxiliar para obter o email do usuário
const getUserEmail = async (): Promise<string | null> => {
let email = GlobalFunctions.getUsuarioLogado().email;
if (!email) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
email = transferEmail as string;
}
}
if (!email) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
email = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
return email || null;
};
// Carrega as despesas da API
const loadExpenses = useCallback(async (page: number = 1) => {
const emailToUse = userEmail || await getUserEmail();
if (!emailToUse) {
setLoadingExpenses(false);
return;
}
if (!userEmail && emailToUse) {
setUserEmail(emailToUse);
}
try {
setLoadingExpenses(true);
// Prepara filtros para a API
const filters: ExpensesFilters = {
page,
per_page: itemsPerPage,
};
if (searchTerm.trim()) {
filters.descricao = searchTerm.trim();
}
if (categoryFilter !== "all") {
// Usa o ID da categoria diretamente
const categoriaId = parseInt(categoryFilter, 10);
if (!isNaN(categoriaId)) {
filters.categoria_id = categoriaId;
}
}
if (startDate) {
filters.data_inicial = format(startDate, "yyyy-MM-dd");
}
if (endDate) {
filters.data_final = format(endDate, "yyyy-MM-dd");
}
const response = await personalAgent.getExpenses(emailToUse, filters);
if (response.success) {
// Garante que a resposta tenha estrutura válida mesmo quando não há dados
const safeResponse: ExpensesResponse = {
success: response.success,
total_registros: response.total_registros || 0,
total_paginas: response.total_paginas || 0,
per_page: response.per_page || itemsPerPage,
pagina_atual: response.pagina_atual || page,
data: Array.isArray(response.data)
? response.data.filter((item) => item && item.id && Object.keys(item).length > 0)
: [],
};
setExpensesData(safeResponse);
} else {
throw new Error("Erro ao carregar despesas");
}
} catch (error: unknown) {
console.error("Erro ao carregar despesas:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar despesas.",
variant: "destructive",
});
} finally {
setLoadingExpenses(false);
}
}, [userEmail, searchTerm, categoryFilter, startDate, endDate, itemsPerPage]);
// Carrega o email do usuário e as categorias na montagem do componente
useEffect(() => {
const initData = async () => {
// Carrega email do usuário
const email = await getUserEmail();
if (email) {
setUserEmail(email);
}
// Carrega categorias
try {
setLoadingCategories(true);
const categoriesData = await personalAgent.getCategories();
setCategories(categoriesData);
} catch (error) {
console.error("Erro ao carregar categorias:", error);
toast({
title: "Aviso",
description: "Não foi possível carregar as categorias. Usando categorias padrão.",
variant: "default",
});
} finally {
setLoadingCategories(false);
}
};
initData();
}, []);
// Debounce para busca de descrição
useEffect(() => {
const timeoutId = setTimeout(() => {
if (userEmail) {
setCurrentPage(1); // Reseta para página 1 ao buscar
loadExpenses(1);
}
}, 500); // Aguarda 500ms após parar de digitar
return () => clearTimeout(timeoutId);
}, [searchTerm, userEmail, loadExpenses]);
// Carrega despesas quando os filtros ou página mudam (exceto searchTerm que tem debounce)
useEffect(() => {
if (userEmail) {
loadExpenses(currentPage);
}
}, [currentPage, typeFilter, categoryFilter, startDate, endDate, userEmail, loadExpenses]);
// Calculate totals - usa dados da API se disponível
const totals = useMemo(() => {
if (indicators && indicators.success) {
return {
total: parseFloat(indicators.total_despesas) || 0,
corporate: parseFloat(indicators.total_corporativo) || 0,
personal: parseFloat(indicators.total_pessoal) || 0,
};
}
return { total: 0, personal: 0, corporate: 0 };
}, [indicators]);
// Filtra despesas por tipo (se necessário, já que a API pode não filtrar por tipo)
const filteredExpenses = useMemo(() => {
if (!expensesData || !expensesData.data || !Array.isArray(expensesData.data)) {
return [];
}
// Filtra objetos vazios ou inválidos
let expenses = expensesData.data.filter((expense) => {
// Verifica se o expense é válido e tem propriedades necessárias
// categoria_id pode ser opcional, então não validamos ele aqui
return expense &&
typeof expense === 'object' &&
expense.id &&
expense.descricao &&
expense.data_hora &&
expense.valor;
});
// Filtra por tipo se necessário (a API pode não ter esse filtro)
if (typeFilter !== "all") {
expenses = expenses.filter((expense) => expense.tipo === typeFilter);
}
return expenses;
}, [expensesData, typeFilter]);
const clearFilters = () => {
setSearchTerm("");
setTypeFilter("all");
setCategoryFilter("all");
setStartDate(undefined);
setEndDate(undefined);
setCurrentPage(1);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold gradient-text">Finanças</h1>
<p className="text-muted-foreground mt-1">
Análise de despesas pessoais e corporativas identificadas pelo agente
</p>
</div>
{/* Indicators */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total de Despesas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.total)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-primary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Corporativas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.corporate)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-secondary/10 flex items-center justify-center">
<Building2 className="w-6 h-6 text-secondary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Pessoais</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.personal)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-warning/10 flex items-center justify-center">
<User className="w-6 h-6 text-warning" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="w-4 h-4" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{/* First row: Search + Date filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Buscar por descrição..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-9"
/>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "dd/MM/yyyy") : "Data inicial"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
setCurrentPage(1);
}}
locale={ptBR}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "dd/MM/yyyy") : "Data final"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={(date) => {
setEndDate(date);
setCurrentPage(1);
}}
locale={ptBR}
disabled={(date) => startDate ? date < startDate : false}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
</div>
{/* Second row: Type + Category + Clear */}
<div className="flex flex-col md:flex-row gap-3">
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os tipos</SelectItem>
<SelectItem value="pessoal">Pessoal</SelectItem>
<SelectItem value="corporativo">Corporativo</SelectItem>
</SelectContent>
</Select>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setCurrentPage(1);
}}
disabled={loadingCategories}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder={loadingCategories ? "Carregando..." : "Categoria"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas categorias</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={clearFilters}>
Limpar filtros
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Receipt className="w-4 h-4" />
Registro de Despesas
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
<div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Data/Hora
</div>
</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-[120px]">Tipo</TableHead>
<TableHead className="w-[130px]">Categoria</TableHead>
<TableHead className="w-[120px] text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingExpenses ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Carregando despesas...</span>
</div>
</TableCell>
</TableRow>
) : filteredExpenses.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
{expensesData && expensesData.total_registros === 0
? "Nenhuma despesa encontrada."
: "Nenhuma despesa encontrada com os filtros aplicados."}
</TableCell>
</TableRow>
) : (
filteredExpenses.map((expense) => {
// Validação adicional para garantir que o expense é válido
if (!expense || !expense.id || !expense.data_hora || !expense.descricao || !expense.valor) {
return null;
}
try {
const { date, time } = formatDateTime(expense.data_hora);
// Usa categoria_id diretamente (pode ser number, undefined ou null)
const categoriaId = expense.categoria_id;
const categoryInfo = getCategoryInfo(categoriaId, categories);
const valor = parseFloat(expense.valor) || 0;
return (
<TableRow key={expense.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{date}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{expense.descricao || "Sem descrição"}</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
expense.tipo === "corporativo"
? "bg-secondary/10 text-secondary border-secondary/20 dark:bg-white/10 dark:text-white dark:border-white/25"
: "bg-warning/10 text-warning border-warning/20"
}
>
{expense.tipo === "corporativo" ? (
<Building2 className="w-3 h-3 mr-1" />
) : (
<User className="w-3 h-3 mr-1" />
)}
{expense.tipo === "corporativo" ? "Corp." : "Pessoal"}
</Badge>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge
variant="outline"
className={categoryInfo.color}
>
{categoryInfo.label}
</Badge>
) : (
<Badge
variant="outline"
className="bg-gray-500/10 text-gray-600 border-gray-500/20"
>
Sem categoria
</Badge>
)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(valor)}
</TableCell>
</TableRow>
);
} catch (error) {
console.error("Erro ao renderizar despesa:", error, expense);
return null;
}
}).filter(Boolean) // Remove nulls do array
)}
</TableBody>
</Table>
{/* Pagination */}
{expensesData && expensesData.total_paginas > 0 && expensesData.total_paginas > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Mostrando {(currentPage - 1) * itemsPerPage + 1} a{" "}
{Math.min(currentPage * itemsPerPage, expensesData.total_registros)} de{" "}
{expensesData.total_registros} registros
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
{Array.from({ length: expensesData.total_paginas }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => {
if (currentPage < expensesData.total_paginas) {
setCurrentPage(currentPage + 1);
}
}}
className={
currentPage === expensesData.total_paginas
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,7 @@
import Dashboard from "./Dashboard";
const Index = () => {
return <Dashboard />;
};
export default Index;
@@ -0,0 +1,310 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { GoogleCalendarCard } from "@/modules/intelligence-ia/components/integrations/GoogleCalendarCard";
import { GoogleSheetsCard } from "@/modules/intelligence-ia/components/integrations/GoogleSheetsCard";
import { AsanaCard } from "@/modules/intelligence-ia/components/integrations/AsanaCard";
import { asanaService } from "@/services/asana";
import { personalAgent } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
export default function Integrations() {
const [asanaWorkspaces, setAsanaWorkspaces] = useState<Workspace[]>([]);
const [asanaUsers, setAsanaUsers] = useState<User[]>([]);
const [loadingAsanaWorkspaces, setLoadingAsanaWorkspaces] = useState(false);
const [loadingAsanaUsers, setLoadingAsanaUsers] = useState(false);
const [asanaToken, setAsanaToken] = useState<string>("");
const [asanaApiKey, setAsanaApiKey] = useState<string>("");
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [loadingIntegration, setLoadingIntegration] = useState(true);
const [hasIntegration, setHasIntegration] = useState(false);
const [integrationId, setIntegrationId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [saving, setSaving] = useState(false);
const handleAsanaSave = async (apiKey: string, workspaceId: string, usuarioAsanaId: string) => {
if (!apiKey || !workspaceId || !usuarioAsanaId) {
toast.error("Preencha todos os campos obrigatórios");
return;
}
// Encontra o nome do workspace e do usuário selecionado
const workspace = asanaWorkspaces.find(w => w.id === workspaceId);
const user = asanaUsers.find(u => u.id === usuarioAsanaId);
if (!workspace || !user) {
toast.error("Workspace ou usuário não encontrado");
return;
}
try {
setSaving(true);
if (hasIntegration && integrationId) {
// Atualiza integração existente
await asanaService.updateIntegration(integrationId, {
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
toast.success("Integração do Asana atualizada com sucesso!");
} else {
// Cria nova integração
if (!userId) {
toast.error("ID do usuário não encontrado");
return;
}
const response = await asanaService.createIntegration({
user_id: userId,
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
if (response.success && response.integracao_id) {
setIntegrationId(response.integracao_id);
setHasIntegration(true);
}
toast.success("Integração do Asana criada com sucesso!");
}
} catch (error: unknown) {
console.error("Erro ao salvar integração do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao salvar integração do Asana";
toast.error(errorMessage);
} finally {
setSaving(false);
}
};
const handleAsanaApiKeyConfirm = async (apiKey: string) => {
if (!apiKey || apiKey.trim().length === 0) {
toast.error("Insira um token válido do Asana");
return;
}
try {
setLoadingAsanaWorkspaces(true);
// Busca workspaces da API do Asana
const workspaces = await asanaService.getWorkspaces(apiKey);
if (workspaces && workspaces.length > 0) {
setAsanaWorkspaces(workspaces);
setAsanaUsers([]);
setAsanaToken(apiKey); // Armazena o token para usar na busca de usuários
toast.success(`${workspaces.length} workspace(s) encontrado(s)`);
} else {
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken("");
toast.warning("Nenhum workspace encontrado para este token");
}
} catch (error: unknown) {
console.error("Erro ao buscar workspaces do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar workspaces do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken(""); // Limpa o token em caso de erro
} finally {
setLoadingAsanaWorkspaces(false);
}
};
const handleAsanaWorkspaceChange = async (workspaceId: string) => {
setSelectedWorkspaceId(workspaceId);
if (!workspaceId || !asanaToken) {
setAsanaUsers([]);
setSelectedUserId("");
return;
}
try {
setLoadingAsanaUsers(true);
// Busca usuários do workspace da API do Asana
const users = await asanaService.getUsers(asanaToken, workspaceId);
if (users && users.length > 0) {
setAsanaUsers(users);
// Não mostra toast ao selecionar workspace (só quando confirmar token)
} else {
setAsanaUsers([]);
setSelectedUserId("");
}
} catch (error: unknown) {
console.error("Erro ao buscar usuários do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar usuários do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaUsers([]);
setSelectedUserId("");
} finally {
setLoadingAsanaUsers(false);
}
};
// Carrega a integração do Asana ao montar o componente
useEffect(() => {
const loadAsanaIntegration = async () => {
try {
setLoadingIntegration(true);
// Obtém o ID do usuário do perfil
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
setLoadingIntegration(false);
return;
}
// Busca o perfil do usuário para obter o ID
const userProfile = await personalAgent.getUserProfile(userEmail);
if (!userProfile.success || !userProfile.id) {
setLoadingIntegration(false);
return;
}
// Armazena o ID do usuário para usar ao criar integração
setUserId(userProfile.id);
// Busca a integração do Asana
const integration = await asanaService.getIntegration(userProfile.id);
if (integration && integration.success) {
// Marca que há integração existente
setHasIntegration(true);
// Armazena o ID da integração (pode vir como integracao_id ou id)
if (integration.integracao_id) {
setIntegrationId(integration.integracao_id);
} else if (integration.id) {
setIntegrationId(integration.id);
}
// Preenche os dados da integração
if (integration.api_key) {
setAsanaApiKey(integration.api_key);
setAsanaToken(integration.api_key);
// Busca workspaces com o token
try {
const workspaces = await asanaService.getWorkspaces(integration.api_key);
setAsanaWorkspaces(workspaces);
// Se tem workspace_gid, seleciona e busca usuários
if (integration.workspace_gid) {
setSelectedWorkspaceId(integration.workspace_gid);
// Busca usuários do workspace
const users = await asanaService.getUsers(integration.api_key, integration.workspace_gid);
setAsanaUsers(users);
// Se tem usuario_asana_gid, seleciona
if (integration.usuario_asana_gid) {
setSelectedUserId(integration.usuario_asana_gid);
}
}
} catch (error) {
console.error("Erro ao carregar workspaces/usuários:", error);
// Continua mesmo se não conseguir carregar workspaces
}
}
} else {
// Não há integração, marca como false
setHasIntegration(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar integração do Asana:", error);
// Não mostra erro para o usuário, apenas deixa os campos vazios
} finally {
setLoadingIntegration(false);
}
};
loadAsanaIntegration();
}, []);
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold text-foreground mb-1">
Integrações
</h1>
<p className="text-muted-foreground">
Configure as conexões do seu agente com serviços externos
</p>
</div>
{/* Integrations Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GoogleCalendarCard />
<GoogleSheetsCard />
<AsanaCard
apiKey={asanaApiKey}
workspaces={asanaWorkspaces}
users={asanaUsers}
selectedWorkspaceId={selectedWorkspaceId}
selectedUserId={selectedUserId}
onSave={handleAsanaSave}
onConfirmApiKey={handleAsanaApiKeyConfirm}
onWorkspaceChange={handleAsanaWorkspaceChange}
loadingWorkspaces={loadingAsanaWorkspaces}
loadingUsers={loadingAsanaUsers}
loadingIntegration={loadingIntegration}
hasIntegration={hasIntegration}
saving={saving}
/>
</div>
</div>
);
}
@@ -0,0 +1,500 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { User, Mail, Phone, Save, Bell, Loader2, Plus } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { personalAgent, UserProfile } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 2) {
return numbers;
} else if (numbers.length <= 7) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
} else if (numbers.length <= 11) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7)}`;
}
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7, 11)}`;
};
const MeuPerfil = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [userExists, setUserExists] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [userId, setUserId] = useState<string>("");
const [nomeCompleto, setNomeCompleto] = useState("");
const [email, setEmail] = useState("");
const [whatsapp, setWhatsapp] = useState("");
const [receberLembretes, setReceberLembretes] = useState(true);
// Estados para o modal de criação
const [createNome, setCreateNome] = useState("");
const [createWhatsapp, setCreateWhatsapp] = useState("");
const [createFollowup, setCreateFollowup] = useState(true);
const [creating, setCreating] = useState(false);
// Verifica se o usuário está logado e carrega os dados
useEffect(() => {
const checkAuthAndLoad = async () => {
// Verifica se há token no sessionStorage primeiro
const jsonUsuario = sessionStorage.getItem('usuarioLogado');
if (!jsonUsuario) {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
// Tenta obter o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta fazer refresh do token
if (!userEmail) {
try {
await GlobalFunctions.getToken();
// Aguarda um pouco para o refresh ser processado (se necessário)
await new Promise(resolve => setTimeout(resolve, 1000));
// Verifica novamente após refresh
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao obter token:", error);
}
}
// Se ainda não tem email após tentar refresh, verifica se o token existe
// Se o token existe mas não tem email, pode ser problema na decodificação
if (!userEmail) {
const usuarioData = GlobalFunctions.getUsuarioLogado();
// Se não tem email mas tem token no sessionStorage, tenta carregar mesmo assim
// O loadUserProfile vai tratar o erro adequadamente se o email for necessário
// Só redireciona se o token estiver completamente inválido (sem UID e sem EID)
if (!usuarioData.email && usuarioData.UID === "0" && usuarioData.EID === "0") {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
}
// Se passou na verificação (ou tem token válido), carrega o perfil
loadUserProfile();
};
checkAuthAndLoad();
}, []);
const loadUserProfile = async () => {
try {
setLoading(true);
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao fazer refresh no loadUserProfile:", error);
}
}
if (!userEmail) {
console.error("MeuPerfil: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoading(false);
// Não redireciona aqui, deixa o usuário ver o erro
return;
}
setEmail(userEmail);
const response = await personalAgent.getUserProfile(userEmail);
if (response.success && response.id) {
// Usuário existe
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
// Usuário não existe
setUserExists(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar perfil:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("não está cadastrado")) {
setUserExists(false);
} else {
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar perfil do usuário.",
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setWhatsapp(formatted);
};
const handleCreateWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setCreateWhatsapp(formatted);
};
const handleSave = async () => {
if (!userExists || !userId) {
toast({
title: "Erro",
description: "Usuário não encontrado. Por favor, crie uma conta primeiro.",
variant: "destructive",
});
return;
}
try {
setSaving(true);
const response = await personalAgent.updateUser(userId, {
nome: nomeCompleto,
whatsapp: whatsapp,
followup: receberLembretes,
});
if (response.success) {
toast({
title: "Perfil atualizado",
description: "Suas informações foram salvas com sucesso.",
});
// Atualiza os dados locais
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
throw new Error(response.message || "Erro ao atualizar perfil");
}
} catch (error: unknown) {
console.error("Erro ao salvar:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao salvar",
description: errorMessage || "Erro ao atualizar perfil. Tente novamente.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleCreateUser = async () => {
if (!createNome.trim()) {
toast({
title: "Erro",
description: "Nome é obrigatório.",
variant: "destructive",
});
return;
}
if (!createWhatsapp.trim()) {
toast({
title: "Erro",
description: "WhatsApp é obrigatório.",
variant: "destructive",
});
return;
}
try {
setCreating(true);
const response = await personalAgent.createUser({
nome: createNome.trim(),
email: email,
whatsapp: createWhatsapp,
followup: createFollowup,
});
if (response.success && response.id) {
toast({
title: "Conta criada",
description: "Sua conta foi criada com sucesso!",
});
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
setShowCreateModal(false);
// Limpa os campos do modal
setCreateNome("");
setCreateWhatsapp("");
setCreateFollowup(true);
} else {
throw new Error(response.message || "Erro ao criar conta");
}
} catch (error: unknown) {
console.error("Erro ao criar usuário:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao criar conta",
description: errorMessage || "Erro ao criar conta. Tente novamente.",
variant: "destructive",
});
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Carregando perfil...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Meu Perfil</h1>
<p className="text-muted-foreground mt-1">
Gerencie suas informações pessoais
</p>
</div>
{!userExists ? (
<Card className="max-w-2xl">
<CardContent className="py-12 text-center">
<div className="space-y-4">
<User className="w-16 h-16 mx-auto text-muted-foreground" />
<div>
<h3 className="text-xl font-semibold mb-2">
Você ainda não possui uma conta
</h3>
<p className="text-muted-foreground mb-6">
Crie sua conta para começar a usar o agente pessoal de IA
</p>
</div>
<Button onClick={() => setShowCreateModal(true)} size="lg">
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Informações Pessoais
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="nomeCompleto" className="flex items-center gap-2">
<User className="w-4 h-4" />
Nome Completo
</Label>
<Input
id="nomeCompleto"
value={nomeCompleto}
onChange={(e) => setNomeCompleto(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
E-mail
</Label>
<Input
id="email"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail não pode ser alterado
</p>
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp" className="flex items-center gap-2">
<Phone className="w-4 h-4" />
WhatsApp
</Label>
<Input
id="whatsapp"
value={whatsapp}
onChange={handleWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="receberLembretes" className="flex items-center gap-2 cursor-pointer">
<Bell className="w-4 h-4" />
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="receberLembretes"
checked={receberLembretes}
onCheckedChange={setReceberLembretes}
/>
</div>
<Button
onClick={handleSave}
className="w-full sm:w-auto"
disabled={saving}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Salvar Alterações
</>
)}
</Button>
</CardContent>
</Card>
)}
{/* Modal de Criação de Conta */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Conta</DialogTitle>
<DialogDescription>
Preencha os dados abaixo para criar sua conta no agente pessoal de IA
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="createNome">Nome Completo</Label>
<Input
id="createNome"
value={createNome}
onChange={(e) => setCreateNome(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="createEmail">E-mail</Label>
<Input
id="createEmail"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail é baseado na sua conta logada
</p>
</div>
<div className="space-y-2">
<Label htmlFor="createWhatsapp">WhatsApp</Label>
<Input
id="createWhatsapp"
value={createWhatsapp}
onChange={handleCreateWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="createFollowup" className="cursor-pointer">
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="createFollowup"
checked={createFollowup}
onCheckedChange={setCreateFollowup}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateModal(false)}
disabled={creating}
>
Cancelar
</Button>
<Button onClick={handleCreateUser} disabled={creating}>
{creating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Criando...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default MeuPerfil;
@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
+139
View File
@@ -0,0 +1,139 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { MainLayout } from "@/modules/intelligence-score/components/layout/MainLayout";
import DashboardPontos from "@/modules/intelligence-score/pages/DashboardPontos";
import Fechamentos from "@/modules/intelligence-score/pages/Fechamentos";
import CompetenciaFechamentos from "@/modules/intelligence-score/pages/CompetenciaFechamentos";
import FechamentoDetalhes from "@/modules/intelligence-score/pages/FechamentoDetalhes";
import BancoPontos from "@/modules/intelligence-score/pages/BancoPontos";
import BancoPontosExtrato from "@/modules/intelligence-score/pages/BancoPontosExtrato";
import Parceiros from "@/modules/intelligence-score/pages/Parceiros";
import Usuarios from "@/modules/intelligence-score/pages/Usuarios";
import Configuracoes from "@/modules/intelligence-score/pages/Configuracoes";
import MeuPerfil from "@/modules/intelligence-score/pages/MeuPerfil";
import MeuFechamento from "@/modules/intelligence-score/pages/MeuFechamento";
import MeuFechamentoBancoPontos from "@/modules/intelligence-score/pages/MeuFechamentoBancoPontos";
import NotFound from "@/modules/intelligence-score/pages/NotFound";
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
import { AuthGate } from "@/components/auth/AuthGate";
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
function RequireStaffRoute({ children }: { children: JSX.Element }) {
const { papel } = useAuthAccess();
if (papel === "admin" || papel === "supervisor") {
return children;
}
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
}
function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element }) {
const { papel } = useAuthAccess();
const location = useLocation();
const readonlyView = Boolean((location.state as { readonlyView?: boolean } | null)?.readonlyView);
if (papel === "admin" || papel === "supervisor") {
return children;
}
if (papel === "parceiro" && readonlyView) {
return children;
}
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
}
const IntelligenceScoreApp = () => {
return (
<AuthAccessProvider>
<AuthGate>
<UnidadeGate>
<MainLayout>
<Routes>
<Route index element={<Navigate to="dashboard" replace />} />
<Route
path="dashboard"
element={
<RequireStaffRoute>
<DashboardPontos />
</RequireStaffRoute>
}
/>
<Route
path="gerenciar-fechamentos"
element={
<RequireStaffRoute>
<Fechamentos />
</RequireStaffRoute>
}
/>
<Route
path="competencias/:id"
element={
<RequireStaffRoute>
<CompetenciaFechamentos />
</RequireStaffRoute>
}
/>
<Route
path="fechamentos/:id"
element={
<RequireFechamentoDetalheRoute>
<FechamentoDetalhes />
</RequireFechamentoDetalheRoute>
}
/>
<Route
path="banco-pontos"
element={
<RequireStaffRoute>
<BancoPontos />
</RequireStaffRoute>
}
/>
<Route
path="banco-pontos/:parceiroId"
element={
<RequireStaffRoute>
<BancoPontosExtrato />
</RequireStaffRoute>
}
/>
<Route path="meu-perfil" element={<MeuPerfil />} />
<Route path="meu-fechamento" element={<MeuFechamento />} />
<Route path="meu-fechamento/banco-pontos" element={<MeuFechamentoBancoPontos />} />
<Route
path="parceiros"
element={
<RequireStaffRoute>
<Parceiros />
</RequireStaffRoute>
}
/>
<Route
path="usuarios"
element={
<RequireStaffRoute>
<Usuarios />
</RequireStaffRoute>
}
/>
<Route
path="configuracoes"
element={
<RequireStaffRoute>
<Configuracoes />
</RequireStaffRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
</UnidadeGate>
</AuthGate>
</AuthAccessProvider>
);
};
export default IntelligenceScoreApp;
@@ -0,0 +1,275 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ClipboardList,
Landmark,
LayoutDashboard,
Menu,
ReceiptText,
Settings,
UserCircle2,
Users,
Wallet,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
import { GlobalFunctions } from "@/GlobalFunctions";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import type { Capabilities, PapelUsuario } from "@/services/fechamento/authMe";
function canSeeNavItem(path: string, caps: Capabilities | undefined, papel: PapelUsuario | null): boolean {
if (!caps || !papel) return false;
if (path === "meu-fechamento" || path === "meu-perfil") {
return papel === "admin" || papel === "parceiro" || papel === "supervisor";
}
if (path === "configuracoes") {
return papel === "admin" || papel === "supervisor";
}
if (path === "dashboard") {
return (papel === "admin" || papel === "supervisor") && caps.competencias.listar;
}
if (path === "gerenciar-fechamentos") return caps.competencias.listar;
if (path === "banco-pontos") return caps.bancoPontos.acessoAdmin;
if (path === "parceiros") return caps.parceiros.listar;
if (path === "usuarios") return caps.usuarios.listar;
return false;
}
const navItems = [
{
section: "Fechamento" as const,
title: "Dashboard",
path: "dashboard",
icon: LayoutDashboard,
},
{
section: "Fechamento" as const,
title: "Gerenciar Fechamentos",
path: "gerenciar-fechamentos",
icon: ClipboardList,
},
{
section: "Fechamento" as const,
title: "Banco de Pontos",
path: "banco-pontos",
icon: Landmark,
},
{
section: "Fechamento" as const,
title: "Meu Fechamento",
path: "meu-fechamento",
icon: ReceiptText,
},
{
section: "Configurações" as const,
title: "Parceiros",
path: "parceiros",
icon: Wallet,
},
{
section: "Configurações" as const,
title: "Usuários",
path: "usuarios",
icon: Users,
},
{
section: "Configurações" as const,
title: "Configurações",
path: "configuracoes",
icon: Settings,
},
{
section: "Configurações" as const,
title: "Meu Perfil",
path: "meu-perfil",
icon: UserCircle2,
},
];
export function AppSidebar() {
const { papel, me } = useAuthAccess();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const caps = me?.capabilities;
const allowedNavItems = navItems.filter((item) => canSeeNavItem(item.path, caps, papel));
const sections = ["Fechamento", "Configurações"] as const;
const painelLabel =
papel === "supervisor" ? "Painel Supervisor" : papel === "admin" ? "Painel Admin" : "Painel Parceiro";
const SidebarContent = () => (
<>
<div
className={cn(
"border-b border-sidebar-border max-lg:pt-12",
collapsed
? "flex flex-col items-center gap-2 px-2 py-3"
: "flex items-center gap-2 px-3 py-4 lg:gap-3",
)}
>
{!collapsed ? (
<>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 animate-fade-in">
<h1 className="truncate text-h2 font-bold text-sidebar-foreground">HGTX Intelligence Score</h1>
<p className="text-xs text-muted-foreground">{painelLabel}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden h-8 w-8 flex-shrink-0 text-muted-foreground hover:text-foreground lg:inline-flex"
title="Recolher menu"
aria-label="Recolher menu"
onClick={() => setCollapsed(true)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden h-8 w-8 text-muted-foreground hover:text-foreground lg:inline-flex"
title="Expandir menu"
aria-label="Expandir menu"
onClick={() => setCollapsed(false)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
</>
)}
</div>
<nav className="flex-1 space-y-3 px-3 py-4">
{sections.map((section) => {
const sectionItems = allowedNavItems.filter((item) => item.section === section);
if (sectionItems.length === 0) {
return null;
}
return (
<div key={section} className="space-y-1">
{!collapsed ? (
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{section}
</p>
) : null}
{sectionItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "dashboard"}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
isActive
? "bg-sidebar-accent font-medium text-primary"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
)
}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</div>
);
})}
</nav>
<div
className={cn(
"mt-auto space-y-2 border-t border-sidebar-border",
collapsed ? "p-3" : "p-4",
)}
>
{collapsed ? (
<div className="flex justify-center">
<ThemeToggle />
</div>
) : (
<div className="flex items-center justify-between px-3">
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
)}
<Button
type="button"
variant="ghost"
onClick={() => {
GlobalFunctions.handleNavigateBack();
setMobileOpen(false);
}}
className={cn(
"w-full gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
collapsed ? "justify-center px-0" : "justify-start",
)}
title="Voltar ao CORE HGTX"
>
<ArrowLeft className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Voltar</span>}
</Button>
</div>
</>
);
return (
<>
<button
onClick={() => {
setCollapsed(false);
setMobileOpen(true);
}}
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-300 lg:hidden",
mobileOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
>
<X className="h-5 w-5" />
</button>
<SidebarContent />
</aside>
<aside
className={cn(
"sticky top-0 hidden h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-300 lg:flex",
collapsed ? "w-[72px]" : "w-64",
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -0,0 +1,18 @@
import { ReactNode } from "react";
import { AppSidebar } from "@/modules/intelligence-score/components/layout/AppSidebar";
type MainLayoutProps = {
children: ReactNode;
};
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 pt-16 lg:p-8 lg:pt-8">{children}</div>
</main>
</div>
);
}
@@ -0,0 +1,261 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Building2, ChevronLeft, ChevronRight, Landmark, Loader2, Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
fechamentoBancoPontosService,
type BancoPontosEstaAtivoFiltro,
type BancoPontosSaldoItem,
} from "@/services/fechamento/bancoPontos";
import { resolveParceiroFotoUrl } from "@/services/fechamento/parceiroFotoUrl";
const PER_PAGE = 20;
function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function iniciais(nome: string): string {
const p = nome.trim().split(/\s+/).slice(0, 2);
return p.map((w) => w[0]?.toUpperCase() ?? "").join("") || "?";
}
function getDisplayNome(row: BancoPontosSaldoItem): string {
if (row.codinome?.trim()) {
return `${row.nome} (${row.codinome.trim()})`;
}
return row.nome;
}
function ParceiroFotoCell({
nome,
logoUrl,
fotoUrl,
}: {
nome: string;
logoUrl: string | null;
fotoUrl?: string | null;
}) {
const [imgErro, setImgErro] = useState(false);
const src = resolveParceiroFotoUrl(fotoUrl ?? logoUrl);
useEffect(() => {
setImgErro(false);
}, [src]);
const classes =
"h-12 w-12 rounded-lg border border-border object-cover";
if (!src || imgErro) {
return (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted text-xs font-semibold text-muted-foreground">
{iniciais(nome)}
</div>
);
}
return (
<img
src={src}
alt=""
className={classes}
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setImgErro(true)}
/>
);
}
export default function BancoPontos() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState<BancoPontosSaldoItem[]>([]);
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
const [buscaInput, setBuscaInput] = useState("");
const [buscaDebounced, setBuscaDebounced] = useState("");
const [estaAtivo, setEstaAtivo] = useState<BancoPontosEstaAtivoFiltro>("all");
const [page, setPage] = useState(1);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await fechamentoBancoPontosService.listarSaldos({
estaAtivo,
busca: buscaDebounced.trim() || undefined,
page,
perPage: PER_PAGE,
});
setRows(res.data ?? []);
setMeta(res.meta);
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao carregar banco de pontos.";
toast.error(msg);
setRows([]);
} finally {
setLoading(false);
}
}, [estaAtivo, buscaDebounced, page]);
useEffect(() => {
const t = window.setTimeout(() => setBuscaDebounced(buscaInput), 350);
return () => window.clearTimeout(t);
}, [buscaInput]);
useEffect(() => {
setPage(1);
}, [estaAtivo, buscaDebounced]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
Banco de Pontos
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Lista de parceiros e saldo; use Detalhes para ver o extrato completo.
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-3 md:p-6">
<div className="flex flex-col gap-3 rounded-lg border border-border/80 bg-card/50 p-4 md:flex-row md:flex-wrap md:items-end">
<div className="min-w-[200px] flex-1 space-y-2">
<Label htmlFor="banco-busca">Buscar parceiro</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="banco-busca"
className="pl-9"
placeholder="Nome ou codinome..."
value={buscaInput}
onChange={(e) => setBuscaInput(e.target.value)}
/>
</div>
</div>
<div className="w-full space-y-2 md:w-48">
<Label>Status</Label>
<Select value={estaAtivo} onValueChange={(v) => setEstaAtivo(v as BancoPontosEstaAtivoFiltro)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="true">Ativos</SelectItem>
<SelectItem value="false">Inativos</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[88px]">Foto</TableHead>
<TableHead className="min-w-[220px]">Nome</TableHead>
<TableHead className="min-w-[120px] text-right">Saldo</TableHead>
<TableHead className="w-[140px] text-center">Detalhes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="py-14 text-center text-muted-foreground">
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
Carregando parceiros...
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
<Building2 className="mx-auto mb-2 h-10 w-10 opacity-40" />
Nenhum parceiro encontrado com os filtros atuais.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.parceiroId}>
<TableCell>
<ParceiroFotoCell
key={row.parceiroId}
nome={row.nome}
logoUrl={row.logoUrl}
fotoUrl={row.fotoUrl}
/>
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell
className={`text-right font-semibold tabular-nums ${
Number(row.saldo) >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{formatPontos(Number(row.saldo))}
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="outline"
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
onClick={() =>
navigate(`/intelligence-score/banco-pontos/${row.parceiroId}`, {
state: { nomeExibicao: getDisplayNome(row) },
})
}
>
Detalhes
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && meta.totalPaginas > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
<p className="text-sm text-muted-foreground">
Página {meta.paginaAtual} de {meta.totalPaginas}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= meta.totalPaginas}
onClick={() => setPage((p) => Math.min(meta.totalPaginas, p + 1))}
>
Próxima
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,446 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
Landmark,
Loader2,
Trash2,
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import {
fechamentoBancoPontosService,
type BancoPontosExtratoItem,
type BancoPontosParceiroExtrato,
} from "@/services/fechamento/bancoPontos";
const PER_PAGE = 20;
function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function formatData(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/** Competência do fechamento (mês/ano), quando a API envia `competenciaMes` e `competenciaAno`. */
function formatCompetenciaMesAno(mes: number | null, ano: number | null): string {
if (mes == null || ano == null) return "—";
const m = Math.trunc(Number(mes));
const y = Math.trunc(Number(ano));
if (!Number.isFinite(m) || !Number.isFinite(y) || m < 1 || m > 12) return "—";
return `${String(m).padStart(2, "0")}/${y}`;
}
type ExtratoLocationState = { nomeExibicao?: string };
function nomeExibicaoParceiro(p: BancoPontosParceiroExtrato): string {
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
}
export default function BancoPontosExtrato() {
const navigate = useNavigate();
const location = useLocation();
const { me } = useAuthAccess();
const podeExcluirTransacao = me?.capabilities.bancoPontos.excluirTransacao ?? false;
const { parceiroId = "" } = useParams();
const nomeDoState = (location.state as ExtratoLocationState | null)?.nomeExibicao?.trim();
const [loading, setLoading] = useState(true);
const [exportando, setExportando] = useState(false);
const [parceiro, setParceiro] = useState<BancoPontosParceiroExtrato | null>(null);
const [linhas, setLinhas] = useState<BancoPontosExtratoItem[]>([]);
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
const [page, setPage] = useState(1);
const [linhaParaExcluir, setLinhaParaExcluir] = useState<BancoPontosExtratoItem | null>(null);
const [motivoExclusao, setMotivoExclusao] = useState("");
const [excluindo, setExcluindo] = useState(false);
const load = useCallback(async () => {
if (!parceiroId) return;
try {
setLoading(true);
const res = await fechamentoBancoPontosService.listarExtrato(parceiroId, {
page,
perPage: PER_PAGE,
});
setParceiro(res.parceiro);
setLinhas(res.data ?? []);
setMeta(res.meta);
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao carregar extrato.";
toast.error(msg);
setParceiro(null);
setLinhas([]);
} finally {
setLoading(false);
}
}, [parceiroId, page]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setPage(1);
setMeta({ total: 0, paginaAtual: 1, totalPaginas: 1 });
}, [parceiroId]);
const tituloParceiro = parceiro
? nomeExibicaoParceiro(parceiro)
: nomeDoState ?? (loading ? "Carregando…" : "Parceiro");
const saldoAtual = parceiro != null ? Number(parceiro.saldo ?? 0) : null;
const saldoFormatado =
parceiro != null && saldoAtual !== null && Number.isFinite(saldoAtual) ? formatPontos(saldoAtual) : null;
const linhasOrdenadas = useMemo(
() =>
[...linhas].sort((a, b) => {
const timeA = new Date(a.criadoEm).getTime();
const timeB = new Date(b.criadoEm).getTime();
if (Number.isNaN(timeA) || Number.isNaN(timeB)) {
return String(a.criadoEm).localeCompare(String(b.criadoEm));
}
return timeA - timeB;
}),
[linhas],
);
const intervaloLabel = (() => {
if (meta.total === 0) return "Mostrando 0 de 0";
const inicio = (page - 1) * PER_PAGE + 1;
const fim = Math.min(page * PER_PAGE, meta.total);
return `Mostrando ${inicio}${fim} de ${meta.total}`;
})();
const handleExcluirTransacao = async () => {
if (!linhaParaExcluir) return;
const motivo = motivoExclusao.trim();
if (motivo.length < 3) {
toast.error("Informe o motivo da exclusão (mínimo 3 caracteres).");
return;
}
try {
setExcluindo(true);
await fechamentoBancoPontosService.excluirTransacao(linhaParaExcluir.id, motivo);
toast.success("Lançamento excluído do extrato.");
setLinhaParaExcluir(null);
setMotivoExclusao("");
await load();
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao excluir lançamento.";
toast.error(msg);
} finally {
setExcluindo(false);
}
};
const handleExportarXlsx = async () => {
if (!parceiroId) return;
try {
setExportando(true);
const { buffer, filename } = await fechamentoBancoPontosService.exportarExtrato(parceiroId);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `banco-pontos-${parceiroId}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Planilha exportada com sucesso.");
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao exportar extrato.";
toast.error(msg);
} finally {
setExportando(false);
}
};
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div className="mb-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/banco-pontos")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar ao banco de pontos
</Button>
</div>
<div className="flex flex-col gap-4">
<div className="min-w-0">
<h1 className="flex flex-wrap items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
<span className="truncate">Extrato {tituloParceiro}</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Histórico de créditos e débitos (mais antigos primeiro).
</p>
{loading ? (
<p className="mt-3 text-sm text-muted-foreground">Carregando saldo atual</p>
) : parceiro != null && saldoFormatado != null ? (
<div className="mt-3 flex flex-wrap items-baseline gap-2 rounded-lg border border-border/80 bg-muted/30 px-3 py-2 md:px-4 md:py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Wallet className="h-4 w-4 shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide">Saldo atual</span>
</div>
<span
className={`text-2xl font-bold tabular-nums md:text-3xl ${
(saldoAtual ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{saldoFormatado}
</span>
<span className="text-xs text-muted-foreground">pontos</span>
</div>
) : null}
</div>
</div>
</div>
<div className="flex-1 space-y-4 p-3 md:p-6">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
disabled={!parceiroId || exportando}
onClick={() => void handleExportarXlsx()}
>
{exportando ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Exportar XLSX
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[150px]">Data</TableHead>
<TableHead className="min-w-[110px] whitespace-nowrap">Competência</TableHead>
<TableHead className="min-w-[280px]">Descrição</TableHead>
<TableHead className="min-w-[100px]">Tipo</TableHead>
<TableHead className="min-w-[130px] text-right">Valor (Pontos)</TableHead>
<TableHead className="min-w-[120px] text-center">Fechamento</TableHead>
{podeExcluirTransacao ? (
<TableHead className="min-w-[80px] text-center">Ações</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={podeExcluirTransacao ? 7 : 6}
className="py-14 text-center text-muted-foreground"
>
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
Carregando extrato...
</TableCell>
</TableRow>
) : linhasOrdenadas.length === 0 ? (
<TableRow>
<TableCell
colSpan={podeExcluirTransacao ? 7 : 6}
className="py-12 text-center text-muted-foreground"
>
Nenhuma movimentação registrada para este parceiro.
</TableCell>
</TableRow>
) : (
linhasOrdenadas.map((linha) => {
const isCredito = linha.tipo === "credito";
const q = Number(linha.quantidade);
return (
<TableRow key={linha.id}>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatData(linha.criadoEm)}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
</TableCell>
<TableCell className="max-w-[420px] text-sm font-medium leading-snug">{linha.descricao}</TableCell>
<TableCell>
{isCredito ? (
<Badge className="border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200">
Crédito
</Badge>
) : (
<Badge className="border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50 dark:bg-red-950/40 dark:text-red-200">
Débito
</Badge>
)}
</TableCell>
<TableCell
className={`text-right font-semibold tabular-nums ${
isCredito ? "text-emerald-600" : "text-red-600"
}`}
>
{isCredito ? "+" : ""}
{formatPontos(q)}
</TableCell>
<TableCell className="text-center">
{linha.fechamentoId ? (
<Button
size="sm"
variant="ghost"
className="gap-1 text-foreground hover:bg-zinc-200 hover:text-foreground dark:hover:bg-zinc-700"
onClick={() =>
navigate(`/intelligence-score/fechamentos/${linha.fechamentoId}`, {
state: { competenciaId: undefined, status: undefined },
})
}
>
<ExternalLink className="h-4 w-4" />
Abrir
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
{podeExcluirTransacao ? (
<TableCell className="text-center">
<Button
type="button"
variant="ghost"
size="icon"
title="Excluir lançamento"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setLinhaParaExcluir(linha);
setMotivoExclusao("");
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
) : null}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<p className="text-sm text-muted-foreground">
{loading ? (
"Carregando paginação…"
) : (
<>
{intervaloLabel}
{" · "}
Página {meta.paginaAtual} de{" "}
{meta.total === 0 ? 1 : Math.max(1, meta.totalPaginas)}
</>
)}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={loading || page >= meta.totalPaginas || meta.totalPaginas < 1}
onClick={() => setPage((p) => Math.min(Math.max(1, meta.totalPaginas), p + 1))}
>
Próxima
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<AlertDialog
open={linhaParaExcluir != null}
onOpenChange={(open) => {
if (!open) {
setLinhaParaExcluir(null);
setMotivoExclusao("");
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir lançamento</AlertDialogTitle>
<AlertDialogDescription>
O lançamento será removido do extrato e do saldo, mas permanece registrado no banco com motivo e
auditoria. Esta ação não pode ser desfeita pela interface.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="motivo-exclusao-banco">Motivo da exclusão</Label>
<Textarea
id="motivo-exclusao-banco"
value={motivoExclusao}
onChange={(e) => setMotivoExclusao(e.target.value)}
placeholder="Descreva o motivo (mínimo 3 caracteres)"
rows={3}
disabled={excluindo}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={excluindo}>Cancelar</AlertDialogCancel>
<AlertDialogAction
disabled={excluindo || motivoExclusao.trim().length < 3}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(e) => {
e.preventDefault();
void handleExcluirTransacao();
}}
>
{excluindo ? "Excluindo..." : "Confirmar exclusão"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,866 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
Download,
ExternalLink,
FileSpreadsheet,
FileText,
FolderKanban,
Loader2,
RefreshCcw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
if (row.parceiroCodinome?.trim()) {
return `${row.parceiroNome} (${row.parceiroCodinome.trim()})`;
}
return row.parceiroNome;
}
/** Pontuação nas colunas da tabela: sempre centésimos (pt-BR). */
function formatPontuacaoDecimal(valor: number): string {
if (!Number.isFinite(valor)) return "—";
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function AsanaImportLoadingCard() {
return (
<div
className="flex flex-col items-center gap-6 py-10 text-center"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-20 w-20 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<Download className="relative h-8 w-8 text-primary" aria-hidden />
</div>
<div className="max-w-sm space-y-2">
<p className="text-lg font-semibold text-foreground">Sincronizando com o Asana</p>
<p className="text-sm text-muted-foreground">
Buscando tarefas concluídas no período da competência e montando os fechamentos. Pode levar um minuto
não feche esta página.
</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-2 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[85%] animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[60%] animate-pulse rounded-full bg-muted" />
</div>
</div>
);
}
function reprocessamentoLabel(modo: "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"): string {
if (modo === "reprocessar_tudo") return "Reimportando toda a competência a partir do Asana.";
if (modo === "reprocessar_alguns") return "Atualizando os parceiros selecionados no Asana.";
return "Buscando novos fechamentos no Asana.";
}
/** Supervisor só exporta fechamento do parceiro ao qual está vinculado. */
function supervisorPodeExportarEsteFechamento(
papel: string | null | undefined,
meParceiroId: string | null | undefined,
fechamentoParceiroId: string,
): boolean {
if (papel !== "supervisor") return true;
return Boolean(meParceiroId && meParceiroId === fechamentoParceiroId);
}
export default function CompetenciaFechamentos() {
const { me, papel } = useAuthAccess();
const caps = me?.capabilities;
const podeImportarAsana = caps?.competencias.importarAsana ?? false;
const podeReabrirCompetencia = caps?.competencias.reabrir ?? false;
const podeConcluirCompetencia = caps?.competencias.concluir ?? false;
const podeExportarPlanilha = caps?.fechamentos.exportarPlanilha ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const podeExcluirFechamento = caps?.fechamentos.excluir ?? false;
const { id: competenciaId = "" } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
/** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */
const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null);
const importing = importKind !== null;
const [exportacao, setExportacao] = useState<{ fechamentoId: string; tipo: "planilha" | "pdf" } | null>(null);
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false);
const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false);
const [concluindoCompetencia, setConcluindoCompetencia] = useState(false);
const [reabrindoCompetencia, setReabrindoCompetencia] = useState(false);
const [motivoReabertura, setMotivoReabertura] = useState("");
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
const [reprocessMode, setReprocessMode] = useState<
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
>("reprocessar_tudo");
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
const [fechamentoExcluir, setFechamentoExcluir] = useState<FechamentoDaCompetenciaItem | null>(null);
const [motivoExclusaoFechamento, setMotivoExclusaoFechamento] = useState("");
const [excluindoFechamento, setExcluindoFechamento] = useState(false);
const loadFechamentos = async () => {
try {
setLoading(true);
const [data, competencias] = await Promise.all([
fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId),
fechamentoCompetenciasService.listarCompetencias({}),
]);
const competenciaAtual = competencias.find((item) => item.id === competenciaId);
setFechamentos(data);
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
} catch (error) {
const message =
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
toast.error(message);
setFechamentos([]);
} finally {
setLoading(false);
}
};
const handleImportarAsana = async () => {
setImportKind("sheet");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
toast.success(
`Importação concluída: ${resultado.tarefasImportadas} tasks, ${resultado.fechamentosCriados} fechamentos criados.`,
);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
toast.error(message);
} finally {
setImportKind(null);
}
};
const handleExecutarReprocessamento = async () => {
if (reprocessMode === "reprocessar_alguns" && selectedParceiroIds.length === 0) {
toast.error("Selecione ao menos um fechamento/parceiro para reprocessar.");
return;
}
setImportKind("modal");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
modo: reprocessMode,
parceiroIds: reprocessMode === "reprocessar_alguns" ? selectedParceiroIds : undefined,
});
if (reprocessMode === "buscar_novos_fechamentos") {
toast.success(
`Busca concluída: ${resultado.fechamentosCriados} novos fechamento(s) criado(s), sem alterar os atuais.`,
);
} else {
toast.success(
`Reprocessamento concluído: ${resultado.tarefasImportadas} tasks processadas e ${resultado.fechamentosCriados} fechamento(s) criado(s).`,
);
}
setIsReprocessModalOpen(false);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
toast.error(message);
} finally {
setImportKind(null);
}
};
const handleConcluirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para concluir a competência.");
return;
}
try {
setConcluindoCompetencia(true);
await fechamentoCompetenciasService.concluirCompetencia(competenciaId, me.id);
toast.success("Competência concluída com sucesso.");
setIsConcluirModalOpen(false);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir competência.";
toast.error(message);
} finally {
setConcluindoCompetencia(false);
}
};
const handleReabrirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para reabrir a competência.");
return;
}
if (!motivoReabertura.trim()) {
toast.error("Informe o motivo da reabertura da competência.");
return;
}
try {
setReabrindoCompetencia(true);
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim());
toast.success("Competência reaberta com sucesso.");
setIsReabrirModalOpen(false);
setMotivoReabertura("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao reabrir competência.";
toast.error(message);
} finally {
setReabrindoCompetencia(false);
}
};
const handleExportar = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "planilha" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Planilha exportada com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
toast.error(message);
} finally {
setExportacao(null);
}
};
const handleExportarPdf = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "pdf" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportacao(null);
}
};
const handleConfirmarExclusaoFechamento = async () => {
if (!fechamentoExcluir || !me?.id) {
toast.error("Não foi possível identificar o usuário ou o fechamento.");
return;
}
if (!motivoExclusaoFechamento.trim()) {
toast.error("Informe o motivo da exclusão.");
return;
}
setExcluindoFechamento(true);
try {
await fechamentoFechamentosService.excluirFechamento(fechamentoExcluir.id, {
excluidoPorId: me.id,
motivo: motivoExclusaoFechamento.trim(),
});
toast.success("Fechamento excluído com sucesso.");
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao excluir fechamento.";
toast.error(message);
} finally {
setExcluindoFechamento(false);
}
};
useEffect(() => {
let cancelled = false;
if (competenciaId) {
void loadFechamentos();
}
return () => {
cancelled = true;
};
}, [competenciaId]);
useEffect(() => {
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
}, [fechamentos]);
const temFechamentos = fechamentos.length > 0;
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/gerenciar-fechamentos")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos da Competência
</h1>
{!loading && (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
{competenciaStatus === "concluido" ? (
podeReabrirCompetencia ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirModalOpen(true)}
disabled={reabrindoCompetencia}
>
{reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
</Button>
) : null
) : temFechamentos ? (
<>
{podeImportarAsana ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReprocessModalOpen(true)}
disabled={importing}
className="hover:bg-muted/60 hover:text-foreground"
>
<RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
) : null}
{podeConcluirCompetencia ? (
<Button
size="sm"
variant="default"
onClick={() => setIsConcluirModalOpen(true)}
disabled={concluindoCompetencia}
>
{concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
</Button>
) : null}
</>
) : null}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && fechamentos.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl border-border/80 shadow-sm">
<CardHeader>
<CardTitle>Nenhum fechamento encontrado</CardTitle>
<CardDescription>
Importe as tasks do Asana para criar os fechamentos automaticamente.
</CardDescription>
</CardHeader>
<div className="px-6 pb-6">
{importKind === "sheet" ? (
<AsanaImportLoadingCard />
) : podeImportarAsana ? (
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" />
Importar tasks do Asana
</Button>
) : (
<p className="text-sm text-muted-foreground">
Não permissão para importar do Asana neste perfil. Peça a um administrador.
</p>
)}
</div>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px]">Logo</TableHead>
<TableHead className="min-w-[280px]">Nome</TableHead>
<TableHead
className="min-w-[140px] text-right"
title="Soma de todas as tarefas do fechamento (revisadas ou não), igual ao total do mês no detalhe e ao dashboard."
>
Pontuação Total
</TableHead>
<TableHead className="min-w-[150px] text-right">Pontuação Paga</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="py-16">
<div
className="flex flex-col items-center justify-center gap-4 text-center"
role="status"
aria-live="polite"
>
<Loader2 className="h-9 w-9 animate-spin text-primary" aria-hidden />
<div>
<p className="font-medium text-foreground">Carregando fechamentos</p>
<p className="mt-1 text-sm text-muted-foreground">Aguarde um instante.</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
fechamentos.map((row) => (
<TableRow key={row.id}>
<TableCell>
{row.parceiroLogoUrl ? (
<img
src={row.parceiroLogoUrl}
alt={`Logo ${row.parceiroNome}`}
className="h-8 w-8 rounded-md object-cover"
/>
) : (
"—"
)}
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell className="text-right tabular-nums">
{formatPontuacaoDecimal(Number(row.pontuacaoTotalEntregue))}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{row.status === "fechado"
? formatPontuacaoDecimal(Number(row.pontuacaoPaga))
: "—"}
</TableCell>
<TableCell>
<Badge
className={
row.status === "fechado"
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-slate-500/25 bg-slate-100 font-normal text-slate-700 hover:bg-slate-100 dark:bg-slate-800/60 dark:text-slate-200"
}
>
{row.status === "fechado" ? "Fechado" : "Em aberto"}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportar(row.id)}
disabled={
!podeExportarPlanilha ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar planilha financeira (XLSX)"
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha"
? "Exportando..."
: "Excel"}
</Button>
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportarPdf(row.id)}
disabled={
!podeExportarPdf ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar documento do fechamento (PDF)"
>
<FileText className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf"
? "Exportando..."
: "PDF"}
</Button>
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() =>
navigate(`/intelligence-score/fechamentos/${row.id}`, {
state: { competenciaId, status: row.status },
})
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Acessar
</Button>
{podeExcluirFechamento ? (
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setMotivoExclusaoFechamento("");
setFechamentoExcluir(row);
}}
title="Excluir fechamento"
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog
open={isReprocessModalOpen}
onOpenChange={(open) => {
if (!open && importing) return;
setIsReprocessModalOpen(open);
}}
>
<DialogContent
className="flex min-h-0 max-h-[85vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:w-full"
hideClose={importing}
onPointerDownOutside={(e) => {
if (importing) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (importing) e.preventDefault();
}}
>
{importKind === "modal" ? (
<div
className="absolute inset-0 z-[60] flex flex-col items-center justify-center gap-4 rounded-b-lg rounded-t-lg bg-background/90 p-6 text-center backdrop-blur-sm sm:rounded-lg"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-16 w-16 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<RefreshCcw className="relative h-7 w-7 text-primary" aria-hidden />
</div>
<div className="max-w-md space-y-2">
<p className="text-lg font-semibold text-foreground">Processando no Asana</p>
<p className="text-sm text-muted-foreground">{reprocessamentoLabel(reprocessMode)}</p>
<p className="text-xs text-muted-foreground">Não feche esta janela até a operação terminar.</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-1.5 w-[75%] animate-pulse rounded-full bg-muted" />
</div>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col gap-4 px-6 pb-0 pt-12 sm:pt-14">
<DialogHeader className="shrink-0 space-y-1.5 pr-8 text-left sm:pr-0">
<DialogTitle>Reprocessar Asana</DialogTitle>
<DialogDescription>
Escolha uma estratégia de reprocessamento para esta competência.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4 pr-1">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => setReprocessMode("reprocessar_tudo")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "reprocessar_tudo"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Reprocessar tudo</p>
<p className="mt-1 text-xs text-muted-foreground">
Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
</p>
</button>
<button
type="button"
onClick={() => setReprocessMode("reprocessar_alguns")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "reprocessar_alguns"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Reprocessar apenas alguns</p>
<p className="mt-1 text-xs text-muted-foreground">
Reprocessa somente os parceiros selecionados abaixo.
</p>
</button>
<button
type="button"
onClick={() => setReprocessMode("buscar_novos_fechamentos")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "buscar_novos_fechamentos"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Buscar novos fechamentos</p>
<p className="mt-1 text-xs text-muted-foreground">
Busca no Asana e cria fechamentos que ainda não existem (inclusive vazios, se o parceiro não tiver tasks no período).
</p>
</button>
</div>
{reprocessMode === "reprocessar_alguns" ? (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">Selecione os fechamentos/parceiros</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
>
Marcar todos
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds([])}
>
Limpar
</Button>
</div>
</div>
{fechamentos.map((f) => {
const checked = selectedParceiroIds.includes(f.parceiroId);
return (
<label
key={f.id}
htmlFor={`sel-${f.id}`}
className={`flex cursor-pointer items-center space-x-3 rounded-md border p-2 transition ${
checked ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
>
<Checkbox
id={`sel-${f.id}`}
checked={checked}
onCheckedChange={(value) => {
const on = Boolean(value);
setSelectedParceiroIds((prev) =>
on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
);
}}
/>
<Label htmlFor={`sel-${f.id}`} className="cursor-pointer text-sm">
{getDisplayNome(f)}
<span className="ml-2 text-xs text-muted-foreground">
{f.status === "fechado" ? "Fechado" : "Em aberto"}
</span>
</Label>
</label>
);
})}
</div>
) : null}
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t bg-background p-4 sm:justify-end">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReprocessModalOpen(false)}
disabled={importing}
>
Cancelar
</Button>
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
Processando...
</>
) : (
"Executar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReabrirModalOpen} onOpenChange={setIsReabrirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reabrir competência</DialogTitle>
<DialogDescription>
Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabrir-competencia">Motivo *</Label>
<Input
id="motivo-reabrir-competencia"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: correção de ajustes pós-fechamento"
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReabrirModalOpen(false)}
disabled={reabrindoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleReabrirCompetencia()}
disabled={reabrindoCompetencia || !motivoReabertura.trim()}
>
{reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isConcluirModalOpen} onOpenChange={setIsConcluirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Concluir competência</DialogTitle>
<DialogDescription>
Deseja concluir esta competência? A operação será permitida se todos os fechamentos estiverem fechados.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsConcluirModalOpen(false)}
disabled={concluindoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleConcluirCompetencia()}
disabled={concluindoCompetencia}
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
>
{concluindoCompetencia ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Confirmar conclusão"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={fechamentoExcluir !== null}
onOpenChange={(open) => {
if (!open) {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Excluir fechamento</DialogTitle>
<DialogDescription>
{fechamentoExcluir ? (
<>
Parceiro: <span className="font-medium text-foreground">{getDisplayNome(fechamentoExcluir)}</span>.
{fechamentoExcluir.status === "fechado" ? (
<>
{" "}
Será aplicado estorno no banco de pontos (como na reabertura). Se a competência estiver
concluída, reabra a competência antes de excluir este fechamento.
</>
) : null}
</>
) : null}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-excluir-fechamento">Motivo *</Label>
<Textarea
id="motivo-excluir-fechamento"
value={motivoExclusaoFechamento}
onChange={(e) => setMotivoExclusaoFechamento(e.target.value)}
placeholder="Ex.: fechamento criado por engano"
rows={3}
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}}
disabled={excluindoFechamento}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirmarExclusaoFechamento()}
disabled={excluindoFechamento || !motivoExclusaoFechamento.trim()}
>
{excluindoFechamento ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,773 @@
import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
import { Separator } from "@/components/ui/separator";
import { useNavigate } from "react-router-dom";
import { Eye, EyeOff, Image as ImageIcon, Loader2, RefreshCw, Save, Upload } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MagicCard } from "@/components/ui/magic-card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { asanaService, type AsanaWorkspace } from "@/services/asana";
import {
fechamentoConfiguracoesService,
type ConfiguracaoPublica,
} from "@/services/fechamento/configuracoes";
import { fechamentoUnidadesService } from "@/services/fechamento/unidades";
import {
clearCommanderUnidadeIdCache,
getEstabelecimentoCodigoFromTransfer,
lookupUnidadeByEstabelecimento,
type UnidadeLookupRow,
} from "@/services/fechamento/unidadeContext";
import { fechamentoUploadsService } from "@/services/fechamento/uploads";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
const MAX_EMPRESA_LOGO_BYTES = 5 * 1024 * 1024;
const ALLOWED_EMPRESA_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
function validateEmpresaLogoFile(file: File): string | null {
if (!ALLOWED_EMPRESA_LOGO_TYPES.has(file.type)) {
return "Arquivo inválido. Use JPG, PNG ou WEBP.";
}
if (file.size > MAX_EMPRESA_LOGO_BYTES) {
return "Arquivo excede 5MB. Escolha uma imagem menor.";
}
return null;
}
export default function Configuracoes() {
const navigate = useNavigate();
const { papel, me } = useAuthAccess();
const caps = me?.capabilities;
const podeSalvarConfig = caps?.configuracoes.salvar ?? false;
const isAdmin = papel === "admin";
const unidadeSectionRef = useRef<HTMLDivElement>(null);
const empresaLogoFileInputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(true);
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [savingEmpresaDoc, setSavingEmpresaDoc] = useState(false);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
const [showToken, setShowToken] = useState(false);
const [configAtual, setConfigAtual] = useState<ConfiguracaoPublica | null>(null);
const [asanaToken, setAsanaToken] = useState("");
const [workspaces, setWorkspaces] = useState<AsanaWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
const [empresaRazaoSocial, setEmpresaRazaoSocial] = useState("");
const [empresaCnpj, setEmpresaCnpj] = useState("");
const [empresaLogoUrl, setEmpresaLogoUrl] = useState("");
const [selectedEmpresaLogoFile, setSelectedEmpresaLogoFile] = useState<File | null>(null);
const [empresaLogoPreviewUrl, setEmpresaLogoPreviewUrl] = useState<string | null>(null);
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null);
const [nomeUnidade, setNomeUnidade] = useState("");
const [loadingUnidade, setLoadingUnidade] = useState(true);
const [savingUnidade, setSavingUnidade] = useState(false);
const tokenJaConfigurado = Boolean(configAtual?.asanaTokenConfigured);
const tokenDigitado = asanaToken.trim();
const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
const empresaLogoDisplayUrl = empresaLogoPreviewUrl ?? (empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null);
const empresaLogoStatusLabel = useMemo(() => {
if (savingEmpresaDoc && selectedEmpresaLogoFile) {
return "Enviando logo e salvando…";
}
if (selectedEmpresaLogoFile) {
return `Arquivo selecionado: ${selectedEmpresaLogoFile.name}`;
}
return "Nenhum arquivo novo selecionado.";
}, [savingEmpresaDoc, selectedEmpresaLogoFile]);
const workspaceOptions = useMemo(() => {
if (!selectedWorkspaceId || !selectedWorkspaceNome) {
return workspaces;
}
if (workspaces.some((item) => item.id === selectedWorkspaceId)) {
return workspaces;
}
return [{ id: selectedWorkspaceId, name: selectedWorkspaceNome }, ...workspaces];
}, [workspaces, selectedWorkspaceId, selectedWorkspaceNome]);
const loadUnidade = async () => {
try {
setLoadingUnidade(true);
const codigo = getEstabelecimentoCodigoFromTransfer();
setCodigoEstabelecimento(codigo);
if (!codigo) {
setUnidadeExistente(null);
setNomeUnidade("");
return;
}
const row = await lookupUnidadeByEstabelecimento(codigo);
setUnidadeExistente(row);
setNomeUnidade(row?.nome ?? "");
} catch {
setUnidadeExistente(null);
setNomeUnidade("");
} finally {
setLoadingUnidade(false);
}
};
const loadConfiguracoesAsana = async () => {
try {
setConfigAsanaError(null);
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
const data = await fechamentoConfiguracoesService.getConfiguracoes();
setConfigAtual(data);
setAsanaToken(data?.asanaToken ?? "");
setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(data?.empresaRazaoSocial ?? "");
setEmpresaCnpj(data?.empresaCnpj ?? "");
setEmpresaLogoUrl(data?.empresaLogoUrl ?? "");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
setConfigAsanaError(message);
setConfigAtual(null);
setEmpresaRazaoSocial("");
setEmpresaCnpj("");
setEmpresaLogoUrl("");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
}
};
const loadAll = async () => {
try {
setLoading(true);
await Promise.all([loadUnidade(), loadConfiguracoesAsana()]);
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
return () => {
if (empresaLogoPreviewUrl) {
URL.revokeObjectURL(empresaLogoPreviewUrl);
}
};
}, [empresaLogoPreviewUrl]);
useEffect(() => {
if (typeof window === "undefined" || loading) return;
if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
unidadeSectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [loading]);
const handleBuscarWorkspaces = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para alterar a integração Asana.");
return;
}
if (!tokenDigitado) {
toast.error("Informe o token do Asana para buscar workspaces.");
return;
}
try {
setLoadingWorkspaces(true);
const ws = await asanaService.getWorkspaces(tokenDigitado);
setWorkspaces(ws);
if (ws.length === 0) {
toast.warning("Nenhum workspace foi encontrado para este token.");
return;
}
if (selectedWorkspaceId && ws.some((item) => item.id === selectedWorkspaceId)) {
const selected = ws.find((item) => item.id === selectedWorkspaceId);
setSelectedWorkspaceNome(selected?.name ?? selectedWorkspaceNome);
} else {
setSelectedWorkspaceId("");
setSelectedWorkspaceNome("");
}
toast.success(`${ws.length} workspace(s) carregado(s).`);
} catch (error) {
const message = error && typeof error === "object" && "message" in error
? String((error as { message: unknown }).message)
: "Erro ao buscar workspaces do Asana.";
toast.error(message);
setWorkspaces([]);
} finally {
setLoadingWorkspaces(false);
}
};
const handleSaveAsana = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
if (!selectedWorkspaceId) {
toast.error("Selecione um workspace antes de salvar.");
return;
}
try {
setSaving(true);
const workspaceSelecionado = workspaceOptions.find((item) => item.id === selectedWorkspaceId);
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
asanaToken: tokenDigitado ? tokenDigitado : undefined,
asanaWorkspaceId: selectedWorkspaceId,
asanaWorkspaceNome: workspaceSelecionado?.name ?? selectedWorkspaceNome,
});
setConfigAtual(updated);
setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setConfigAsanaError(null);
toast.success("Configurações salvas com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar configurações.";
toast.error(message);
} finally {
setSaving(false);
}
};
const handleEmpresaLogoFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const validationMessage = validateEmpresaLogoFile(file);
if (validationMessage) {
toast.error(validationMessage);
event.currentTarget.value = "";
return;
}
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return URL.createObjectURL(file);
});
setSelectedEmpresaLogoFile(file);
};
const clearEmpresaLogoSelection = () => {
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
setEmpresaLogoUrl("");
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
};
const handleSalvarEmpresaDoc = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
try {
setSavingEmpresaDoc(true);
let logoUrlParaSalvar: string | null = null;
if (selectedEmpresaLogoFile) {
const signed = await fechamentoUploadsService.presignUploadEmpresaLogo({
fileName: selectedEmpresaLogoFile.name,
contentType: selectedEmpresaLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
});
await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedEmpresaLogoFile);
logoUrlParaSalvar = signed.publicUrl;
} else {
logoUrlParaSalvar = empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null;
}
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
empresaRazaoSocial: empresaRazaoSocial.trim() || null,
empresaCnpj: empresaCnpj.trim() || null,
empresaLogoUrl: logoUrlParaSalvar,
});
setConfigAtual(updated);
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setConfigAsanaError(null);
toast.success("Dados da empresa salvos.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar dados da empresa.";
toast.error(message);
} finally {
setSavingEmpresaDoc(false);
}
};
const handleSalvarUnidade = async () => {
const nome = nomeUnidade.trim();
if (!nome) {
toast.error("Informe o nome da unidade.");
return;
}
const codigo = codigoEstabelecimento.trim();
if (!codigo) {
toast.error("Código do estabelecimento não disponível no transfer.");
return;
}
try {
setSavingUnidade(true);
if (unidadeExistente) {
await fechamentoUnidadesService.updateUnidade(unidadeExistente.id, { nome });
toast.success("Unidade atualizada.");
clearCommanderUnidadeIdCache();
await loadUnidade();
} else {
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
toast.success("Unidade cadastrada.");
clearCommanderUnidadeIdCache();
navigate("/intelligence-score", { replace: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
toast.error(message);
} finally {
setSavingUnidade(false);
}
};
if (loading) {
return (
<div className="flex min-h-[300px] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Carregando configurações...</span>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl space-y-8 pb-8">
<header className="border-b border-border pb-6">
<h1 className="text-xl font-semibold tracking-tight text-foreground md:text-2xl">Configurações do sistema</h1>
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto
atual do Commander.
</p>
</header>
<div ref={unidadeSectionRef} id="unidade">
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-0 border-b bg-muted/30 px-6 py-4">
<CardTitle id="titulo-codigo-estabelecimento" className="text-base font-semibold">
Código do estabelecimento
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{loadingUnidade ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Carregando dados da unidade...
</div>
) : !isAdmin ? (
<p className="text-sm text-muted-foreground">
Apenas administradores podem cadastrar ou editar a unidade aqui.
</p>
) : !codigoEstabelecimento ? (
<p className="text-sm text-muted-foreground">
Abra o módulo pelo Commander com o estabelecimento no transfer para exibir o código e cadastrar a
unidade.
</p>
) : (
<>
<div className="space-y-2">
<Input
id="codigo-estabelecimento"
aria-labelledby="titulo-codigo-estabelecimento"
value={codigoEstabelecimento}
readOnly
className="h-11 font-mono text-sm bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nome-unidade">Nome da unidade</Label>
<Input
id="nome-unidade"
value={nomeUnidade}
onChange={(e) => setNomeUnidade(e.target.value)}
placeholder="Ex.: Unidade Matriz"
className="h-11 text-sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{unidadeExistente ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Unidade cadastrada
</Badge>
) : (
<Badge variant="outline" className="max-w-full shrink-0 whitespace-normal sm:whitespace-nowrap">
Pendente: informe o nome e salve para criar a unidade
</Badge>
)}
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button type="button" onClick={handleSalvarUnidade} disabled={savingUnidade}>
{savingUnidade ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</MagicCard>
</Card>
</div>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Dados da empresa</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Razão social, CNPJ e logo usados no cabeçalho do PDF de fechamento (estilo invoice). Envie JPG, PNG ou
WEBP de até 5MB.
</CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{configAsanaError ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-950 dark:text-amber-100">
A integração Asana não pôde ser carregada; você ainda pode salvar os dados da empresa para o PDF.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor="empresa-razao">Razão social</Label>
<Input
id="empresa-razao"
value={empresaRazaoSocial}
onChange={(e) => setEmpresaRazaoSocial(e.target.value)}
placeholder="Ex.: Minha Empresa LTDA"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label htmlFor="empresa-cnpj">CNPJ</Label>
<Input
id="empresa-cnpj"
value={empresaCnpj}
onChange={(e) => setEmpresaCnpj(e.target.value)}
placeholder="Somente números ou formatado"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label>Logo da empresa</Label>
<div className="flex flex-col items-start gap-3 rounded-md border border-border bg-muted/20 px-4 py-4 sm:flex-row sm:items-center">
<button
type="button"
className="group relative h-24 w-40 shrink-0 overflow-hidden rounded-md border bg-background shadow-sm transition hover:brightness-95 disabled:pointer-events-none disabled:opacity-50"
onClick={() => empresaLogoFileInputRef.current?.click()}
title="Clique para enviar uma imagem"
disabled={!podeSalvarConfig || savingEmpresaDoc}
>
{empresaLogoDisplayUrl ? (
<img
src={empresaLogoDisplayUrl}
alt="Pré-visualização do logo"
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100">
<span className="inline-flex items-center gap-1 text-xs font-medium">
<Upload className="h-3.5 w-3.5" />
Enviar imagem
</span>
</div>
</button>
<input
ref={empresaLogoFileInputRef}
id="empresa-logo-file"
type="file"
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={handleEmpresaLogoFileChange}
className="hidden"
disabled={!podeSalvarConfig}
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs leading-relaxed text-muted-foreground">{empresaLogoStatusLabel}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={clearEmpresaLogoSelection}
disabled={!podeSalvarConfig || savingEmpresaDoc || (!empresaLogoDisplayUrl && !selectedEmpresaLogoFile)}
>
Remover logo
</Button>
</div>
</div>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
onClick={() => void handleSalvarEmpresaDoc()}
disabled={savingEmpresaDoc || !podeSalvarConfig}
>
{savingEmpresaDoc ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar dados da empresa
</>
)}
</Button>
</div>
</CardContent>
</MagicCard>
</Card>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
</CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader>
<CardContent className="space-y-6 px-6 py-6">
{configAsanaError ? (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<p className="font-medium">Não foi possível carregar as configurações do Asana</p>
<p className="mt-1 leading-relaxed opacity-90">
{configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
</p>
</div>
) : null}
<div className="space-y-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<Label htmlFor="asana-token" className="text-sm font-medium">
Token de acesso
</Label>
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
{tokenJaConfigurado ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Token configurado no servidor
</Badge>
) : (
<Badge variant="outline" className="shrink-0 whitespace-nowrap">
Token ainda não salvo
</Badge>
)}
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="relative min-w-0 flex-1">
<Input
id="asana-token"
type={showToken ? "text" : "password"}
value={asanaToken}
onChange={(e) => setAsanaToken(e.target.value)}
placeholder={
tokenJaConfigurado
? "Substitua o token ou mantenha em branco para não alterar ao salvar"
: "Cole o token do Asana (Personal Access Token)"
}
className="h-11 pr-11 font-mono text-sm"
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Ocultar token" : "Mostrar token"}
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
type="button"
variant="outline"
className="h-11 shrink-0 whitespace-nowrap bg-background px-5 hover:bg-muted/60 hover:text-foreground dark:bg-muted/30 dark:hover:bg-muted/50 lg:self-stretch"
onClick={handleBuscarWorkspaces}
disabled={!podeSalvarConfig || !podeBuscarWorkspaces || Boolean(configAsanaError)}
>
{loadingWorkspaces ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Buscando
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Listar workspaces
</>
)}
</Button>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="asana-workspace" className="text-sm font-medium">
Workspace padrão
</Label>
<Select
value={selectedWorkspaceId}
onValueChange={(value) => {
setSelectedWorkspaceId(value);
const selected = workspaceOptions.find((item) => item.id === value);
setSelectedWorkspaceNome(selected?.name ?? "");
}}
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
>
<SelectTrigger id="asana-workspace" className="h-11 w-full">
<SelectValue
placeholder={
workspaceOptions.length > 0
? "Selecione um workspace"
: "Informe o token e clique em Listar workspaces"
}
/>
</SelectTrigger>
<SelectContent>
{workspaceOptions.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
Selecionado: <span className="font-medium text-foreground">{selectedWorkspaceNome || "—"}</span>
</p>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
size="lg"
className="min-w-[200px]"
onClick={handleSaveAsana}
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError) || !podeSalvarConfig}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar integração Asana
</>
)}
</Button>
</div>
</CardContent>
</MagicCard>
</Card>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More