Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff508f471 | |||
| 521ca9743f | |||
| 8a77c0dd31 | |||
| 34ae317fed | |||
| 422107331b | |||
| fc5fb3cc15 | |||
| 0b5c7350c5 | |||
| 6acfee2c76 | |||
| b4622c9166 | |||
| 517c9751bc | |||
| 343aae602f | |||
| 1c1e62c7db | |||
| 7859230468 | |||
| dee9ecfbca | |||
| d570029890 | |||
| b19e2963c4 | |||
| f2cc21f282 | |||
| 4c28862c5c | |||
| 6da067c641 | |||
| 7a01c6f97b | |||
| 8f2e4f1d21 | |||
| 8d8818c32e | |||
| 6537e2dd53 | |||
| 6bf7e7d42e | |||
| 39032ea621 | |||
| 71f9c9ab86 | |||
| d75d80e9c7 |
+2
-4
@@ -14,7 +14,5 @@ VITE_API_KEY=your-api-key-here
|
|||||||
VITE_USER_EMAIL=user@example.com
|
VITE_USER_EMAIL=user@example.com
|
||||||
VITE_ESTABELECIMENTO_ID=1
|
VITE_ESTABELECIMENTO_ID=1
|
||||||
|
|
||||||
# Instruções:
|
# API do módulo de fechamento (Commander)
|
||||||
# 1. Copie este arquivo para .env
|
VITE_API_BASE_URL_COMMANDER=https://hgtx-opencommander-api.goutron.com.br/api
|
||||||
# 2. Substitua os valores acima com as configurações reais
|
|
||||||
# 3. NUNCA commite o arquivo .env no git
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 12–15% opac. | sombra azul 28–35% 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.*
|
||||||
+5
-5
@@ -3,12 +3,12 @@
|
|||||||
<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="/favicon.png" />
|
<meta property="og:image" content="/favicon.png" />
|
||||||
|
|
||||||
|
|||||||
Generated
+1411
-26
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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,7 +40,9 @@
|
|||||||
"@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",
|
"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",
|
||||||
@@ -49,11 +51,13 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"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.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
+18
-16
@@ -4,35 +4,39 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } 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 Redirect from "./pages/Redirect";
|
||||||
import { GlobalFunctions } from "./GlobalFunctions";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import CommanderApp from "./modules/commander/App";
|
||||||
import IntelligenceIAApp from "./modules/intelligence-ia/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
|
<Route path="/redir/:gsikey" element={<Redirect />} />
|
||||||
path="/redir/:gsikey"
|
|
||||||
element={<Redirect />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Módulo Intelligence IA como subpath*/}
|
<Route path="/commander/*" element={<CommanderApp />} />
|
||||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
||||||
|
<Route path="/intelligence-score/*" element={<IntelligenceScoreApp />} />
|
||||||
|
|
||||||
<Route path="/*" element={<Index />} />
|
<Route path="/" element={<Navigate to="/commander" replace />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
<Route path="/404" element={<NotFound />} />
|
<Route path="/404" element={<NotFound />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -42,12 +46,10 @@ const App = () => (
|
|||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
const HGTXLogin = () => {
|
||||||
const HGTXLogin = () =>{
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||||
});
|
});
|
||||||
|
|
||||||
return(<></>);
|
return <></>;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const ChatHeader = ({
|
|||||||
<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">
|
<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-2 md: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>
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export const ChatHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } 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";
|
||||||
@@ -7,11 +7,11 @@ import { GlobalFunctions } from "@/GlobalFunctions";
|
|||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas";
|
||||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent") => void;
|
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "prompts" | "parecerJuridico" | "areas") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = "chat" | "images" | "transcription" | "generation" | "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);
|
||||||
@@ -22,7 +22,9 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
||||||
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||||
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
{ 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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
Já 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">
|
||||||
|
Já 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`;
|
toast({
|
||||||
document.body.appendChild(a);
|
title: "Download iniciado",
|
||||||
a.click();
|
description: "O arquivo de áudio está sendo baixado.",
|
||||||
document.body.removeChild(a);
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
toast({
|
const message =
|
||||||
title: "Download iniciado",
|
error && typeof error === "object" && "message" in error ? String((error as { message: string }).message) : "Não foi possível baixar o áudio.";
|
||||||
description: "O arquivo de áudio está sendo baixado.",
|
toast({
|
||||||
});
|
title: "Erro ao baixar",
|
||||||
|
description: message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAudio = (audioId: string) => {
|
const handleDeleteAudio = (audioId: string) => {
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -134,18 +134,23 @@ export const GenerationView = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadAudio = (audio: AudioRecord) => {
|
const handleDownloadAudio = async (audio: AudioRecord) => {
|
||||||
const a = document.createElement('a');
|
const filename = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
|
||||||
a.href = audio.audio_url;
|
try {
|
||||||
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
|
await audioGenerationService.downloadAudioFile(audio.audio_url, filename);
|
||||||
document.body.appendChild(a);
|
toast({
|
||||||
a.click();
|
title: "Download iniciado",
|
||||||
document.body.removeChild(a);
|
description: "O arquivo de áudio está sendo baixado.",
|
||||||
|
});
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Download iniciado",
|
const message =
|
||||||
description: "O arquivo de áudio está sendo baixado.",
|
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) => {
|
const handleDeleteAudio = async (audioId: string) => {
|
||||||
@@ -290,10 +295,11 @@ export const GenerationView = () => {
|
|||||||
</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(lastGeneratedAudio)}
|
onClick={() => void handleDownloadAudio(lastGeneratedAudio)}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
Baixar
|
Baixar
|
||||||
@@ -385,9 +391,10 @@ export const GenerationView = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleDownloadAudio(audio)}
|
onClick={() => void handleDownloadAudio(audio)}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,22 +4,43 @@ 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 { 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;
|
||||||
@@ -31,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,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);
|
||||||
@@ -70,7 +105,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const newFiles = [...attachedFiles, ...files];
|
const newFiles = [...attachedFiles, ...files];
|
||||||
|
|
||||||
// Valida quantidade máxima de anexos (5)
|
|
||||||
if (newFiles.length > chatService.getMaxAttachments()) {
|
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||||
toast({
|
toast({
|
||||||
title: "Limite de anexos excedido",
|
title: "Limite de anexos excedido",
|
||||||
@@ -83,10 +117,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valida tipos de arquivo e tamanho
|
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Valida tamanho (max 10MB por arquivo)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: "Arquivo muito grande",
|
title: "Arquivo muito grande",
|
||||||
@@ -99,7 +131,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valida formatos permitidos
|
|
||||||
const allFiles = [...attachedFiles, ...validFiles];
|
const allFiles = [...attachedFiles, ...validFiles];
|
||||||
const validation = chatService.validateAttachments(allFiles);
|
const validation = chatService.validateAttachments(allFiles);
|
||||||
|
|
||||||
@@ -125,96 +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-2 md: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-2 md: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-1 md: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-1 md:gap-2 px-2 md:px-3 py-1 md:py-1.5 rounded-lg bg-muted border border-border text-xs md: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 truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
<span className="truncate font-medium">{file.name}</span>
|
||||||
<span className="hidden md:inline 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-1 md:gap-2 items-end">
|
<div
|
||||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
onMouseDown={handleMouseDown}
|
||||||
{/* Resize Handle - Hidden on mobile */}
|
className={cn(
|
||||||
<div
|
"hidden h-5 w-full cursor-ns-resize items-center justify-center border-b border-border/40 transition-colors md:flex",
|
||||||
onMouseDown={handleMouseDown}
|
isDragging ? "bg-primary/15" : "hover:bg-primary/8",
|
||||||
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
)}
|
||||||
isDragging ? 'bg-primary/20' : ''
|
title="Arraste para aumentar a altura"
|
||||||
}`}
|
>
|
||||||
title="Arraste para cima para aumentar"
|
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
>
|
|
||||||
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Textarea */}
|
|
||||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
|
||||||
<Textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Digite sua mensagem..."
|
|
||||||
style={{ height: window.innerWidth < 768 ? '80px' : `${textareaHeight}px` }}
|
|
||||||
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm md:text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
|
||||||
<div className="flex gap-1 md:gap-2">
|
<Textarea
|
||||||
<input
|
ref={textareaRef}
|
||||||
ref={fileInputRef}
|
value={message}
|
||||||
type="file"
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
multiple
|
onKeyDown={handleKeyDown}
|
||||||
className="hidden"
|
placeholder="Digite sua mensagem..."
|
||||||
onChange={handleFileSelect}
|
style={{ height: textareaStyleHeight }}
|
||||||
accept={chatService.getAllowedFileExtensions()}
|
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"
|
||||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 min-w-0 items-center gap-1">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
accept={chatService.getAllowedFileExtensions()}
|
||||||
|
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Anexar arquivo"
|
||||||
|
aria-label="Anexar arquivo"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{attachedFiles.length} anexo{attachedFiles.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
title="Anexar arquivo"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 md:h-11 md: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-4 h-4 md:w-5 md:h-5" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="hidden md:block 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. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()}{" "}
|
||||||
|
anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -53,7 +54,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex-1 max-w-3xl space-y-1 md: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-3 md:px-4 py-2 md: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={`text-sm md:text-base 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 && (
|
||||||
|
|||||||
+384
-359
@@ -1,5 +1,18 @@
|
|||||||
import { useState, useEffect } 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";
|
||||||
@@ -11,6 +24,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +47,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
@@ -42,7 +57,19 @@ interface ChatSidebarProps {
|
|||||||
currentChatId?: string;
|
currentChatId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: 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 { toast } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
@@ -56,49 +83,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Estado carregado do banco de dados via chatService
|
|
||||||
const [chats, setChats] = useState<ChatRecord[]>([]);
|
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||||
|
/** Pastas fechadas por padrão; estado preservado ao recarregar lista */
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Carrega chats e pastas do banco de dados quando o componente monta
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChatsAndFolders();
|
loadChatsAndFolders();
|
||||||
|
|
||||||
// Escuta evento customizado para recarregar quando chat é atualizado
|
|
||||||
const handleChatUpdate = () => {
|
const handleChatUpdate = () => {
|
||||||
loadChatsAndFolders();
|
loadChatsAndFolders();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('chatUpdated', handleChatUpdate);
|
window.addEventListener("chatUpdated", handleChatUpdate);
|
||||||
|
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('chatUpdated', handleChatUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChatsAndFolders = async () => {
|
const loadChatsAndFolders = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await chatService.getChatsAndFolders();
|
const data = await chatService.getChatsAndFolders();
|
||||||
|
|
||||||
console.log('Dados carregados:', data);
|
|
||||||
|
|
||||||
setChats(data.chats);
|
setChats(data.chats);
|
||||||
setFolders(data.folders);
|
setFolders(data.folders);
|
||||||
|
} catch (error: unknown) {
|
||||||
// Expande todas as pastas por padrão
|
const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
|
||||||
setExpandedFolders(new Set(data.folders.map(f => f.id)));
|
console.error("Erro ao carregar chats e pastas:", error);
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao carregar chats e pastas:', error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Erro ao carregar dados",
|
title: "Erro ao carregar dados",
|
||||||
description: error.message || "Não foi possível carregar chats e pastas.",
|
description: message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define arrays vazios em caso de erro
|
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,48 +121,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (newFolderName.trim()) {
|
if (!newFolderName.trim()) return;
|
||||||
try {
|
const name = newFolderName.trim();
|
||||||
await chatService.createFolder(newFolderName);
|
try {
|
||||||
await loadChatsAndFolders();
|
await chatService.createFolder(name);
|
||||||
setNewFolderName("");
|
await loadChatsAndFolders();
|
||||||
setIsCreateFolderOpen(false);
|
setNewFolderName("");
|
||||||
|
setIsCreateFolderOpen(false);
|
||||||
toast({
|
toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
|
||||||
title: "Pasta criada",
|
} catch (error: unknown) {
|
||||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
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" });
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao criar pasta:', error);
|
|
||||||
toast({
|
|
||||||
title: "Erro ao criar pasta",
|
|
||||||
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = async () => {
|
const handleDeleteFolder = async () => {
|
||||||
if (deletingFolder) {
|
if (!deletingFolder) return;
|
||||||
try {
|
try {
|
||||||
await chatService.deleteFolder(deletingFolder.id);
|
await chatService.deleteFolder(deletingFolder.id);
|
||||||
await loadChatsAndFolders();
|
setExpandedFolders((prev) => {
|
||||||
setDeletingFolder(null);
|
const next = new Set(prev);
|
||||||
setIsDeleteFolderOpen(false);
|
next.delete(deletingFolder.id);
|
||||||
|
return next;
|
||||||
toast({
|
});
|
||||||
title: "Pasta excluída",
|
await loadChatsAndFolders();
|
||||||
description: "A pasta foi excluída com sucesso.",
|
setDeletingFolder(null);
|
||||||
});
|
setIsDeleteFolderOpen(false);
|
||||||
} catch (error: any) {
|
toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
|
||||||
console.error('Erro ao excluir pasta:', error);
|
} catch (error: unknown) {
|
||||||
toast({
|
const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
|
||||||
title: "Erro ao excluir pasta",
|
toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
|
||||||
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,79 +166,52 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameFolder = async () => {
|
const handleRenameFolder = async () => {
|
||||||
if (renamingFolder && renamedFolderName.trim()) {
|
if (!renamingFolder || !renamedFolderName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setRenamingFolder(null);
|
setRenamingFolder(null);
|
||||||
setRenamedFolderName("");
|
setRenamedFolderName("");
|
||||||
setIsRenameFolderOpen(false);
|
setIsRenameFolderOpen(false);
|
||||||
|
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Pasta renomeada",
|
const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
|
||||||
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
|
toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao renomear pasta:', error);
|
|
||||||
toast({
|
|
||||||
title: "Erro ao renomear pasta",
|
|
||||||
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFolder = (folderId: string) => {
|
const toggleFolder = (folderId: string) => {
|
||||||
const newExpanded = new Set(expandedFolders);
|
setExpandedFolders((prev) => {
|
||||||
if (newExpanded.has(folderId)) {
|
const next = new Set(prev);
|
||||||
newExpanded.delete(folderId);
|
if (next.has(folderId)) next.delete(folderId);
|
||||||
} else {
|
else next.add(folderId);
|
||||||
newExpanded.add(folderId);
|
return next;
|
||||||
}
|
});
|
||||||
setExpandedFolders(newExpanded);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveToFolder = async (chatId: string, folderId: string) => {
|
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||||
try {
|
try {
|
||||||
await chatService.moveChatToFolder(chatId, folderId);
|
await chatService.moveChatToFolder(chatId, folderId);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
|
const folder = folders.find((f) => f.id === folderId);
|
||||||
const folder = folders.find(f => f.id === folderId);
|
toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Chat movido",
|
const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
|
||||||
description: `Movido para a pasta "${folder?.name}".`,
|
toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao mover chat:', error);
|
|
||||||
toast({
|
|
||||||
title: "Erro ao mover chat",
|
|
||||||
description: error.message || "Não foi possível mover o chat. Tente novamente.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteChat = async () => {
|
const handleDeleteChat = async () => {
|
||||||
if (deletingChat) {
|
if (!deletingChat) return;
|
||||||
try {
|
try {
|
||||||
await chatService.deleteChat(deletingChat.id);
|
await chatService.deleteChat(deletingChat.id);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setDeletingChat(null);
|
setDeletingChat(null);
|
||||||
setIsDeleteChatOpen(false);
|
setIsDeleteChatOpen(false);
|
||||||
|
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Chat excluído",
|
const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
|
||||||
description: "A conversa foi excluída com sucesso.",
|
toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao excluir chat:', error);
|
|
||||||
toast({
|
|
||||||
title: "Erro ao excluir chat",
|
|
||||||
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,287 +221,318 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectChat = (chat: ChatRecord) => {
|
const handleSelectChat = (chat: ChatRecord) => {
|
||||||
if (onSelectChat) {
|
onSelectChat?.(chat);
|
||||||
onSelectChat(chat);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrar chats pela busca
|
const filteredChats = chats.filter((chat) =>
|
||||||
const filteredChats = chats.filter((chat) => {
|
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
const searchLower = searchQuery.toLowerCase();
|
);
|
||||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
|
||||||
return titleMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Separar chats sem pasta (folder_id é null)
|
|
||||||
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
|
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
|
||||||
|
|
||||||
// Agrupar chats por pasta (quando folder_id === folder.id)
|
const chatsByFolder = folders.reduce(
|
||||||
const chatsByFolder = folders.reduce((acc, folder) => {
|
(acc, folder) => {
|
||||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, ChatRecord[]>);
|
},
|
||||||
|
{} 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 h-full 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 h-full 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" />
|
||||||
</Button>
|
Novo chat
|
||||||
|
</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.
|
</DialogHeader>
|
||||||
</DialogDescription>
|
<div className="space-y-2 py-2">
|
||||||
</DialogHeader>
|
<Label htmlFor="folder-name">Nome</Label>
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<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
|
||||||
Criar Pasta
|
</Button>
|
||||||
</Button>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
</div>
|
||||||
|
|
||||||
{/* Rename Folder Dialog */}
|
|
||||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
|
||||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Renomear Pasta</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
|
||||||
<Input
|
|
||||||
id="rename-folder-name"
|
|
||||||
value={renamedFolderName}
|
|
||||||
onChange={(e) => setRenamedFolderName(e.target.value)}
|
|
||||||
placeholder="Ex: Projetos, Estudos..."
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
|
||||||
Renomear
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Folder Alert */}
|
|
||||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
|
||||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDeleteFolder} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Excluir
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversations List */}
|
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||||
<ScrollArea className="flex-1">
|
<DialogContent className="border-border bg-card">
|
||||||
<div className="p-2 space-y-1">
|
<DialogHeader>
|
||||||
|
<DialogTitle>Renomear pasta</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Novo nome para "{renamingFolder?.name}".
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 py-2">
|
||||||
|
<Label htmlFor="rename-folder-name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="rename-folder-name"
|
||||||
|
value={renamedFolderName}
|
||||||
|
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||||
|
<AlertDialogContent className="border-border bg-card">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
A pasta "{deletingFolder?.name}" será excluída. As conversas dentro dela
|
||||||
|
ficarão sem pasta.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
<div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Folders */}
|
{visibleFolders.length > 0 && (
|
||||||
{folders.map((folder) => (
|
<section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
|
||||||
<div key={folder.id} className="space-y-1">
|
{visibleFolders.map((folder) => {
|
||||||
<div className="flex items-center gap-1">
|
const folderChats = chatsByFolder[folder.id] ?? [];
|
||||||
<button
|
const isExpanded = expandedFolders.has(folder.id);
|
||||||
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">
|
|
||||||
{chatsByFolder[folder.id]?.length || 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
return (
|
||||||
<DropdownMenuTrigger asChild>
|
<div key={folder.id} className="min-w-0">
|
||||||
<Button
|
<div className="flex min-w-0 items-center">
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
type="button"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
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"
|
||||||
<MoreVertical className="w-3 h-3" />
|
aria-expanded={isExpanded}
|
||||||
</Button>
|
>
|
||||||
</DropdownMenuTrigger>
|
<ChevronDown
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
className={cn(
|
||||||
<DropdownMenuItem
|
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||||
className="gap-2"
|
!isExpanded && "-rotate-90"
|
||||||
onClick={() => openRenameFolder(folder)}
|
)}
|
||||||
>
|
/>
|
||||||
<Edit className="w-3 h-3" />
|
<Folder className="h-4 w-4 shrink-0 text-primary/90" />
|
||||||
Renomear Pasta
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
</DropdownMenuItem>
|
{folder.name}
|
||||||
<DropdownMenuItem
|
</span>
|
||||||
className="gap-2 text-destructive"
|
<span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||||
onClick={() => openDeleteFolder(folder)}
|
{folderChats.length}
|
||||||
>
|
</span>
|
||||||
<Trash2 className="w-3 h-3" />
|
</button>
|
||||||
Excluir Pasta
|
<DropdownMenu>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuContent>
|
<Button
|
||||||
</DropdownMenu>
|
variant="ghost"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
{isExpanded && (
|
||||||
<div className="ml-6 space-y-1">
|
<div className="min-w-0 space-y-0.5 py-0.5 pl-5">
|
||||||
{chatsByFolder[folder.id].map((chat) => (
|
{folderChats.length > 0 ? (
|
||||||
<ChatItem
|
folderChats.map((chat) => (
|
||||||
key={chat.id}
|
<ChatItem
|
||||||
chat={chat}
|
key={chat.id}
|
||||||
isSelected={currentChatId === chat.id}
|
chat={chat}
|
||||||
onSelect={() => handleSelectChat(chat)}
|
isSelected={currentChatId === chat.id}
|
||||||
folders={folders}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
onMoveToFolder={moveToFolder}
|
folders={folders}
|
||||||
onDelete={() => openDeleteChat(chat)}
|
onMoveToFolder={moveToFolder}
|
||||||
/>
|
onDelete={() => openDeleteChat(chat)}
|
||||||
))}
|
nested
|
||||||
</div>
|
/>
|
||||||
)}
|
))
|
||||||
</div>
|
) : (
|
||||||
))}
|
<p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chats without folder */}
|
|
||||||
{chatsWithoutFolder.length > 0 && (
|
{chatsWithoutFolder.length > 0 && (
|
||||||
<div className="space-y-1">
|
<section
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
className={cn(
|
||||||
Sem Pasta
|
"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>
|
</div>
|
||||||
{chatsWithoutFolder.map((chat) => (
|
</section>
|
||||||
<ChatItem
|
)}
|
||||||
key={chat.id}
|
|
||||||
chat={chat}
|
{!isLoading && chats.length === 0 && (
|
||||||
isSelected={currentChatId === chat.id}
|
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||||
onSelect={() => handleSelectChat(chat)}
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
|
||||||
folders={folders}
|
<Inbox className="h-6 w-6 text-muted-foreground" />
|
||||||
onMoveToFolder={moveToFolder}
|
</div>
|
||||||
onDelete={() => openDeleteChat(chat)}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{!isLoading &&
|
||||||
{!isLoading && chats.length === 0 && (
|
chats.length > 0 &&
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
filteredChats.length === 0 &&
|
||||||
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
searchQuery.trim() && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||||
Nenhuma conversa ainda
|
Nenhum resultado para "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
)}
|
||||||
Clique em "Novo Chat" para começar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Delete Chat Alert */}
|
|
||||||
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
<AlertDialogContent className="border-border bg-card">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
"{deletingChat?.title}" será excluída permanentemente.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteChat}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
Excluir
|
Excluir
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -543,6 +549,7 @@ interface ChatItemProps {
|
|||||||
folders: FolderRecord[];
|
folders: FolderRecord[];
|
||||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
nested?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatItem = ({
|
const ChatItem = ({
|
||||||
@@ -552,8 +559,8 @@ const ChatItem = ({
|
|||||||
folders,
|
folders,
|
||||||
onMoveToFolder,
|
onMoveToFolder,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
nested = false,
|
||||||
}: ChatItemProps) => {
|
}: ChatItemProps) => {
|
||||||
// Formata timestamp relativo
|
|
||||||
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
@@ -561,19 +568,34 @@ const ChatItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
|
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 text-primary flex-shrink-0" />
|
<MessageSquare
|
||||||
<div className="flex-1 min-w-0 max-w-[180px]">
|
className={cn(
|
||||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
"h-4 w-4 shrink-0",
|
||||||
|
isSelected ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium leading-snug" title={chat.title}>
|
||||||
{chat.title}
|
{chat.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
<p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,29 +605,32 @@ const ChatItem = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
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">
|
||||||
{/* Opções para mover para pastas */}
|
{folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
|
||||||
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
<>
|
||||||
<DropdownMenuItem
|
{folders
|
||||||
key={folder.id}
|
.filter((f) => f.id !== chat.folder_id)
|
||||||
className="gap-2"
|
.map((folder) => (
|
||||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
<DropdownMenuItem
|
||||||
>
|
key={folder.id}
|
||||||
<Folder className="w-3 h-3" />
|
className="gap-2"
|
||||||
Mover para {folder.name}
|
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
))}
|
<Folder className="h-3.5 w-3.5" />
|
||||||
|
Mover para {folder.name}
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
className="gap-2 text-destructive"
|
))}
|
||||||
onClick={onDelete}
|
<DropdownMenuSeparator />
|
||||||
>
|
</>
|
||||||
<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>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ 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 { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -11,18 +18,6 @@ import { getModelId } from "@/config/models";
|
|||||||
// Texto padrão da personalidade (usado para novos chats)
|
// Texto padrão da personalidade (usado para novos chats)
|
||||||
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
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 { toast } = useToast();
|
||||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||||
@@ -31,16 +26,17 @@ export const ChatView = () => {
|
|||||||
const [currentChatId, setCurrentChatId] = useState("0");
|
const [currentChatId, setCurrentChatId] = useState("0");
|
||||||
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
||||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: "GPT-4o",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [inputDraft, setInputDraft] = useState<{
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (prompt: string) => {
|
||||||
|
setInputDraft({ text: prompt, at: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
||||||
// quando enviadas via handleSendMessage
|
// quando enviadas via handleSendMessage
|
||||||
@@ -82,14 +78,7 @@ export const ChatView = () => {
|
|||||||
// Reseta a personalidade para o texto padrão
|
// Reseta a personalidade para o texto padrão
|
||||||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||||
|
|
||||||
setMessages([
|
setMessages([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: selectedModel,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Dispara evento para atualizar a sidebar
|
// Dispara evento para atualizar a sidebar
|
||||||
window.dispatchEvent(new Event('chatUpdated'));
|
window.dispatchEvent(new Event('chatUpdated'));
|
||||||
@@ -136,14 +125,7 @@ export const ChatView = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Inicia com mensagem padrão em caso de erro
|
// Inicia com mensagem padrão em caso de erro
|
||||||
setMessages([
|
setMessages([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: selectedModel,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -162,7 +144,7 @@ export const ChatView = () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, newMessage]);
|
setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -234,7 +216,7 @@ export const ChatView = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full pb-16 md:pb-0">
|
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||||
{/* Chat Sidebar - Hidden on mobile */}
|
{/* Chat Sidebar - Hidden on mobile */}
|
||||||
<div className="hidden md:block">
|
<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)}
|
||||||
@@ -256,15 +238,22 @@ export const ChatView = () => {
|
|||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||||
{messages.map((message) => (
|
{messages.map((message) =>
|
||||||
<ChatMessage
|
isWelcomeMessage(message) ? (
|
||||||
key={message.id}
|
<ChatWelcome
|
||||||
role={message.role}
|
key={message.id}
|
||||||
content={message.content}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
model={message.model}
|
/>
|
||||||
attachments={message.attachments}
|
) : (
|
||||||
/>
|
<ChatMessage
|
||||||
))}
|
key={message.id}
|
||||||
|
role={message.role}
|
||||||
|
content={message.content}
|
||||||
|
model={message.model}
|
||||||
|
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">
|
||||||
@@ -281,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
model?: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
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 há 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">
|
||||||
|
> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 string’s 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
<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">
|
{!hideClose && (
|
||||||
<X className="h-4 w-4" />
|
<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">
|
||||||
<span className="sr-only">Close</span>
|
<X className="h-4 w-4" />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+278
-71
@@ -6,115 +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%;
|
||||||
|
|
||||||
--success: 142 71% 45%;
|
/* Sucesso: #28a745 */
|
||||||
|
--success: 134 61% 41%;
|
||||||
--success-foreground: 0 0% 100%;
|
--success-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 50%;
|
||||||
--warning-foreground: 222 47% 11%;
|
--warning-foreground: 209 11% 15%;
|
||||||
|
|
||||||
--border: 214 32% 91%;
|
/* Borda: #e5e7eb */
|
||||||
--input: 214 32% 91%;
|
--border: 220 13% 91%;
|
||||||
--ring: 190 100% 45%;
|
/* --input no shadcn = cor da BORDA de Input/Select/Textarea */
|
||||||
|
--input: 220 9% 83%;
|
||||||
|
--ring: 210 100% 33%;
|
||||||
|
|
||||||
--radius: 0.75rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* Sidebar specific - Light */
|
/* Sidebar — Light */
|
||||||
--sidebar-background: 210 40% 99%;
|
--sidebar-background: 210 17% 98%;
|
||||||
--sidebar-foreground: 222 47% 11%;
|
--sidebar-foreground: 209 11% 15%;
|
||||||
--sidebar-primary: 190 100% 45%;
|
--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%;
|
||||||
|
|
||||||
--success: 142 71% 45%;
|
/* Sucesso dark: #23d160 */
|
||||||
--success-foreground: 222 47% 5%;
|
--success: 141 71% 48%;
|
||||||
|
--success-foreground: 216 48% 8%;
|
||||||
|
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 50%;
|
||||||
--warning-foreground: 222 47% 5%;
|
--warning-foreground: 216 48% 8%;
|
||||||
|
|
||||||
--border: 222 30% 18%;
|
/* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
|
||||||
--input: 222 30% 18%;
|
--border: 216 18% 26%;
|
||||||
--ring: 190 100% 50%;
|
/* --input no shadcn = cor da BORDA de Input/Select/Textarea (não o fundo) */
|
||||||
|
--input: 216 16% 30%;
|
||||||
|
--ring: 210 90% 52%;
|
||||||
|
|
||||||
/* Sidebar specific - Dark */
|
/* Sidebar — Dark */
|
||||||
--sidebar-background: 222 45% 7%;
|
--sidebar-background: 216 50% 9%;
|
||||||
--sidebar-foreground: 200 100% 90%;
|
--sidebar-foreground: 0 0% 100%;
|
||||||
--sidebar-primary: 190 100% 50%;
|
--sidebar-primary: 210 90% 52%;
|
||||||
--sidebar-primary-foreground: 222 47% 5%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 222 35% 12%;
|
--sidebar-accent: 210 45% 16%;
|
||||||
--sidebar-accent-foreground: 190 100% 60%;
|
--sidebar-accent-foreground: 210 85% 62%;
|
||||||
--sidebar-border: 222 30% 15%;
|
--sidebar-border: 212 12% 21%;
|
||||||
--sidebar-ring: 190 100% 50%;
|
--sidebar-ring: 210 90% 52%;
|
||||||
|
|
||||||
/* Custom gradients - Dark */
|
/* Tokens extras — Dark */
|
||||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 50%) 0%, hsl(210 100% 50%) 100%);
|
--bubble-bot: 216 36% 14%;
|
||||||
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%);
|
--nav-active: 210 45% 16%;
|
||||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3);
|
--input-bg: 216 40% 10%;
|
||||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25);
|
--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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,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;
|
||||||
@@ -158,15 +272,22 @@
|
|||||||
|
|
||||||
.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 {
|
.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;
|
@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 {
|
.nav-item {
|
||||||
@@ -224,3 +345,89 @@
|
|||||||
opacity: 0.5;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -27,12 +28,12 @@ export function AppSidebar() {
|
|||||||
<>
|
<>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
|
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
|
||||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center cyber-glow">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||||
<Bot className="w-5 h-5 text-primary" />
|
<Bot className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<h1 className="text-sidebar-foreground font-semibold text-lg">AI Agent</h1>
|
<h1 className="text-sidebar-foreground font-bold text-h2">AI Agent</h1>
|
||||||
<p className="text-muted-foreground text-xs">Painel Admin</p>
|
<p className="text-muted-foreground text-xs">Painel Admin</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -61,16 +62,36 @@ export function AppSidebar() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse button - desktop only */}
|
<div
|
||||||
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
|
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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full justify-center lg:justify-start text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
|
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
|
<ChevronLeft
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
|
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||||
collapsed && "rotate-180"
|
collapsed && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{!collapsed && <span>Recolher</span>}
|
{!collapsed && <span>Recolher</span>}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} from "@/components/ui/pagination";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { userProfileService, FinancialIndicators, ExpenseItem, ExpensesResponse, ExpensesFilters, ExpenseCategory } from "@/services/userProfile";
|
import { personalAgent, FinancialIndicators, ExpenseItem, ExpensesResponse, ExpensesFilters, ExpenseCategory } from "@/services/personalAgent";
|
||||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export default function Financas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Busca os indicadores financeiros da API
|
// Busca os indicadores financeiros da API
|
||||||
const response = await userProfileService.getFinancialIndicators(userEmail);
|
const response = await personalAgent.getFinancialIndicators(userEmail);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setIndicators(response);
|
setIndicators(response);
|
||||||
@@ -265,7 +265,7 @@ export default function Financas() {
|
|||||||
filters.data_final = format(endDate, "yyyy-MM-dd");
|
filters.data_final = format(endDate, "yyyy-MM-dd");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await userProfileService.getExpenses(emailToUse, filters);
|
const response = await personalAgent.getExpenses(emailToUse, filters);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// Garante que a resposta tenha estrutura válida mesmo quando não há dados
|
// Garante que a resposta tenha estrutura válida mesmo quando não há dados
|
||||||
@@ -308,8 +308,7 @@ export default function Financas() {
|
|||||||
// Carrega categorias
|
// Carrega categorias
|
||||||
try {
|
try {
|
||||||
setLoadingCategories(true);
|
setLoadingCategories(true);
|
||||||
const categoriesData = await userProfileService.getCategories();
|
const categoriesData = await personalAgent.getCategories();
|
||||||
console.log("Categorias carregadas:", categoriesData);
|
|
||||||
setCategories(categoriesData);
|
setCategories(categoriesData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao carregar categorias:", error);
|
console.error("Erro ao carregar categorias:", error);
|
||||||
@@ -675,7 +674,7 @@ export default function Financas() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={
|
className={
|
||||||
expense.tipo === "corporativo"
|
expense.tipo === "corporativo"
|
||||||
? "bg-secondary/10 text-secondary border-secondary/20"
|
? "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"
|
: "bg-warning/10 text-warning border-warning/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { GoogleCalendarCard } from "@/modules/intelligence-ia/components/integra
|
|||||||
import { GoogleSheetsCard } from "@/modules/intelligence-ia/components/integrations/GoogleSheetsCard";
|
import { GoogleSheetsCard } from "@/modules/intelligence-ia/components/integrations/GoogleSheetsCard";
|
||||||
import { AsanaCard } from "@/modules/intelligence-ia/components/integrations/AsanaCard";
|
import { AsanaCard } from "@/modules/intelligence-ia/components/integrations/AsanaCard";
|
||||||
import { asanaService } from "@/services/asana";
|
import { asanaService } from "@/services/asana";
|
||||||
import { userProfileService } from "@/services/userProfile";
|
import { personalAgent } from "@/services/personalAgent";
|
||||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
|
||||||
interface Workspace {
|
interface Workspace {
|
||||||
@@ -199,16 +199,14 @@ export default function Integrations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
console.log("Integrations: Não foi possível obter email do usuário");
|
|
||||||
setLoadingIntegration(false);
|
setLoadingIntegration(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Busca o perfil do usuário para obter o ID
|
// Busca o perfil do usuário para obter o ID
|
||||||
const userProfile = await userProfileService.getUserProfile(userEmail);
|
const userProfile = await personalAgent.getUserProfile(userEmail);
|
||||||
|
|
||||||
if (!userProfile.success || !userProfile.id) {
|
if (!userProfile.success || !userProfile.id) {
|
||||||
console.log("Integrations: Usuário não encontrado ou sem ID");
|
|
||||||
setLoadingIntegration(false);
|
setLoadingIntegration(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { User, Mail, Phone, Save, Bell, Loader2, Plus } from "lucide-react";
|
import { User, Mail, Phone, Save, Bell, Loader2, Plus } from "lucide-react";
|
||||||
import { toast } from "@/hooks/use-toast";
|
import { toast } from "@/hooks/use-toast";
|
||||||
import { userProfileService, UserProfile } from "@/services/userProfile";
|
import { personalAgent, UserProfile } from "@/services/personalAgent";
|
||||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
|
||||||
const formatPhoneNumber = (value: string) => {
|
const formatPhoneNumber = (value: string) => {
|
||||||
@@ -54,20 +54,15 @@ const MeuPerfil = () => {
|
|||||||
// Verifica se há token no sessionStorage primeiro
|
// Verifica se há token no sessionStorage primeiro
|
||||||
const jsonUsuario = sessionStorage.getItem('usuarioLogado');
|
const jsonUsuario = sessionStorage.getItem('usuarioLogado');
|
||||||
if (!jsonUsuario) {
|
if (!jsonUsuario) {
|
||||||
console.log("MeuPerfil: Não há token no sessionStorage");
|
|
||||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
|
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("MeuPerfil: Token encontrado no sessionStorage");
|
|
||||||
|
|
||||||
// Tenta obter o email do usuário logado
|
// Tenta obter o email do usuário logado
|
||||||
let userEmail = GlobalFunctions.getUsuarioLogado().email;
|
let userEmail = GlobalFunctions.getUsuarioLogado().email;
|
||||||
console.log("MeuPerfil: Email obtido:", userEmail);
|
|
||||||
|
|
||||||
// Se não tem email, tenta fazer refresh do token
|
// Se não tem email, tenta fazer refresh do token
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
console.log("MeuPerfil: Email vazio, tentando fazer refresh do token");
|
|
||||||
try {
|
try {
|
||||||
await GlobalFunctions.getToken();
|
await GlobalFunctions.getToken();
|
||||||
// Aguarda um pouco para o refresh ser processado (se necessário)
|
// Aguarda um pouco para o refresh ser processado (se necessário)
|
||||||
@@ -75,7 +70,6 @@ const MeuPerfil = () => {
|
|||||||
|
|
||||||
// Verifica novamente após refresh
|
// Verifica novamente após refresh
|
||||||
userEmail = GlobalFunctions.getUsuarioLogado().email;
|
userEmail = GlobalFunctions.getUsuarioLogado().email;
|
||||||
console.log("MeuPerfil: Email após refresh:", userEmail);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("MeuPerfil: Erro ao obter token:", error);
|
console.error("MeuPerfil: Erro ao obter token:", error);
|
||||||
}
|
}
|
||||||
@@ -85,24 +79,17 @@ const MeuPerfil = () => {
|
|||||||
// Se o token existe mas não tem email, pode ser problema na decodificação
|
// Se o token existe mas não tem email, pode ser problema na decodificação
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
const usuarioData = GlobalFunctions.getUsuarioLogado();
|
const usuarioData = GlobalFunctions.getUsuarioLogado();
|
||||||
console.log("MeuPerfil: Dados do usuário após tentativas:", usuarioData);
|
|
||||||
|
|
||||||
// Se não tem email mas tem token no sessionStorage, tenta carregar mesmo assim
|
// 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
|
// 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)
|
// Só redireciona se o token estiver completamente inválido (sem UID e sem EID)
|
||||||
if (!usuarioData.email && usuarioData.UID === "0" && usuarioData.EID === "0") {
|
if (!usuarioData.email && usuarioData.UID === "0" && usuarioData.EID === "0") {
|
||||||
console.log("MeuPerfil: Token completamente inválido (sem UID/EID), redirecionando");
|
|
||||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
|
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se tem token mas não tem email, pode ser que o email esteja em outro campo
|
|
||||||
// ou o token precisa ser renovado. Tenta carregar o perfil mesmo assim.
|
|
||||||
console.log("MeuPerfil: Token existe mas email vazio, tentando carregar perfil mesmo assim");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se passou na verificação (ou tem token válido), carrega o perfil
|
// Se passou na verificação (ou tem token válido), carrega o perfil
|
||||||
console.log("MeuPerfil: Carregando perfil do usuário");
|
|
||||||
loadUserProfile();
|
loadUserProfile();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,13 +103,11 @@ const MeuPerfil = () => {
|
|||||||
|
|
||||||
// Se não tem email, tenta obter do Transfer Area como fallback
|
// Se não tem email, tenta obter do Transfer Area como fallback
|
||||||
if (!userEmail) {
|
if (!userEmail) {
|
||||||
console.log("MeuPerfil: Email não encontrado no token, tentando Transfer Area");
|
|
||||||
const transferEmail = GlobalFunctions.getTransferProperty(
|
const transferEmail = GlobalFunctions.getTransferProperty(
|
||||||
TransferAreaProperties.UsuarioEmail
|
TransferAreaProperties.UsuarioEmail
|
||||||
);
|
);
|
||||||
if (transferEmail) {
|
if (transferEmail) {
|
||||||
userEmail = transferEmail as string;
|
userEmail = transferEmail as string;
|
||||||
console.log("MeuPerfil: Email do Transfer Area:", userEmail);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +117,6 @@ const MeuPerfil = () => {
|
|||||||
await GlobalFunctions.getToken();
|
await GlobalFunctions.getToken();
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
userEmail = GlobalFunctions.getUsuarioLogado().email;
|
userEmail = GlobalFunctions.getUsuarioLogado().email;
|
||||||
console.log("MeuPerfil: Email após refresh no loadUserProfile:", userEmail);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("MeuPerfil: Erro ao fazer refresh no loadUserProfile:", error);
|
console.error("MeuPerfil: Erro ao fazer refresh no loadUserProfile:", error);
|
||||||
}
|
}
|
||||||
@@ -151,7 +135,7 @@ const MeuPerfil = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEmail(userEmail);
|
setEmail(userEmail);
|
||||||
const response = await userProfileService.getUserProfile(userEmail);
|
const response = await personalAgent.getUserProfile(userEmail);
|
||||||
|
|
||||||
if (response.success && response.id) {
|
if (response.success && response.id) {
|
||||||
// Usuário existe
|
// Usuário existe
|
||||||
@@ -203,7 +187,7 @@ const MeuPerfil = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const response = await userProfileService.updateUser(userId, {
|
const response = await personalAgent.updateUser(userId, {
|
||||||
nome: nomeCompleto,
|
nome: nomeCompleto,
|
||||||
whatsapp: whatsapp,
|
whatsapp: whatsapp,
|
||||||
followup: receberLembretes,
|
followup: receberLembretes,
|
||||||
@@ -255,7 +239,7 @@ const MeuPerfil = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const response = await userProfileService.createUser({
|
const response = await personalAgent.createUser({
|
||||||
nome: createNome.trim(),
|
nome: createNome.trim(),
|
||||||
email: email,
|
email: email,
|
||||||
whatsapp: createWhatsapp,
|
whatsapp: createWhatsapp,
|
||||||
|
|||||||
@@ -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 há 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 só 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { format, startOfDay } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import { CalendarIcon, ChevronDown, LayoutDashboard, Loader2, RefreshCw, Sparkles } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
import {
|
||||||
|
dashboardPontosPorClienteService,
|
||||||
|
type DashboardPontosPorParceiro,
|
||||||
|
} from "@/services/fechamento/dashboardPontosPorCliente";
|
||||||
|
import { fechamentoParceirosService, type ParceiroItem } from "@/services/fechamento/parceiros";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function formatPontos(valor: number): string {
|
||||||
|
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primeiro dia do mês civil (hora local). */
|
||||||
|
function inicioDoMesAtual(ref = new Date()): Date {
|
||||||
|
return new Date(ref.getFullYear(), ref.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Último dia do mês civil (hora local). */
|
||||||
|
function fimDoMesAtual(ref = new Date()): Date {
|
||||||
|
return new Date(ref.getFullYear(), ref.getMonth() + 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToApiYmd(d: Date): string {
|
||||||
|
return format(d, "yyyy-MM-dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceColor(i: number): string {
|
||||||
|
const hue = (i * 47 + 198) % 360;
|
||||||
|
return `hsl(${hue} 72% ${52 - (i % 4) * 4}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DistribuicaoClientesDonut({ bloco }: { bloco: DashboardPontosPorParceiro }) {
|
||||||
|
const total = bloco.totalParceiro > 0 ? bloco.totalParceiro : 1;
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
bloco.linhas.map((l) => ({
|
||||||
|
name: l.cliente,
|
||||||
|
value: l.pontos,
|
||||||
|
})),
|
||||||
|
[bloco.linhas],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[220px] items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Sem clientes neste período.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[min(320px,55vw)] w-full min-h-[220px] min-w-0">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="48%"
|
||||||
|
innerRadius="42%"
|
||||||
|
outerRadius="72%"
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeWidth={1}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
(percent ?? 0) >= 0.06
|
||||||
|
? `${String(name).slice(0, 14)}${String(name).length > 14 ? "…" : ""} ${(((percent ?? 0) as number) * 100).toFixed(0)}%`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<Cell key={`${bloco.parceiroId}-${i}`} fill={sliceColor(i)} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.[0]) return null;
|
||||||
|
const row = payload[0].payload as { name: string; value: number };
|
||||||
|
const pct = total > 0 ? (row.value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/80 bg-background/95 px-3 py-2 text-xs shadow-lg backdrop-blur">
|
||||||
|
<p className="font-medium">{row.name}</p>
|
||||||
|
<p className="tabular-nums text-muted-foreground">
|
||||||
|
{formatPontos(row.value)} pts · {pct.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={44}
|
||||||
|
formatter={(value) => (
|
||||||
|
<span className="text-[11px] text-muted-foreground">{String(value).slice(0, 28)}</span>
|
||||||
|
)}
|
||||||
|
wrapperStyle={{ paddingTop: 4 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPontos() {
|
||||||
|
const { papel, me } = useAuthAccess();
|
||||||
|
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
||||||
|
const [dataInicio, setDataInicio] = useState(() => inicioDoMesAtual());
|
||||||
|
const [dataFim, setDataFim] = useState(() => fimDoMesAtual());
|
||||||
|
/** `null` = todos os parceiros (sem filtro na API). */
|
||||||
|
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[] | null>(null);
|
||||||
|
const [parceirosMenuOpen, setParceirosMenuOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dados, setDados] = useState<DashboardPontosPorParceiro[]>([]);
|
||||||
|
|
||||||
|
const parceiroFixoSupervisor = papel === "supervisor" && me?.parceiroId;
|
||||||
|
|
||||||
|
const selectionKey = selectedPartnerIds === null ? "all" : [...selectedPartnerIds].sort().join(",");
|
||||||
|
const periodoKey = `${dataInicio.getTime()}-${dataFim.getTime()}`;
|
||||||
|
|
||||||
|
const loadParceiros = useCallback(async () => {
|
||||||
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
||||||
|
try {
|
||||||
|
const res = await fechamentoParceirosService.listarParceiros({
|
||||||
|
estaAtivo: "all",
|
||||||
|
page: 1,
|
||||||
|
perPage: 200,
|
||||||
|
});
|
||||||
|
const um = res.data.find((p) => p.id === me.parceiroId);
|
||||||
|
setParceiros(um ? [um] : []);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
||||||
|
toast.error(message);
|
||||||
|
setParceiros([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const lista = await fechamentoParceirosService.listarParceirosAtivos();
|
||||||
|
setParceiros(lista);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
||||||
|
toast.error(message);
|
||||||
|
setParceiros([]);
|
||||||
|
}
|
||||||
|
}, [me?.parceiroId, parceiroFixoSupervisor]);
|
||||||
|
|
||||||
|
const loadRelatorio = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
let parceiroIds: string[] | undefined;
|
||||||
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
||||||
|
parceiroIds = [me.parceiroId];
|
||||||
|
} else if (selectedPartnerIds !== null && selectedPartnerIds.length > 0) {
|
||||||
|
parceiroIds = selectedPartnerIds;
|
||||||
|
}
|
||||||
|
if (startOfDay(dataFim) < startOfDay(dataInicio)) {
|
||||||
|
toast.error("A data final não pode ser anterior à inicial.");
|
||||||
|
setDados([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await dashboardPontosPorClienteService.relatorioPontosPorCliente({
|
||||||
|
dataInicio: dateToApiYmd(dataInicio),
|
||||||
|
dataFim: dateToApiYmd(dataFim),
|
||||||
|
parceiroIds,
|
||||||
|
});
|
||||||
|
setDados(res);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Erro ao carregar dashboard.";
|
||||||
|
toast.error(message);
|
||||||
|
setDados([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, periodoKey, selectionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadParceiros();
|
||||||
|
}, [loadParceiros]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRelatorio();
|
||||||
|
}, [loadRelatorio, me?.parceiroId, papel]);
|
||||||
|
|
||||||
|
const allParceiroIds = useMemo(() => parceiros.map((p) => p.id), [parceiros]);
|
||||||
|
|
||||||
|
const parceirosSelecaoLabel = useMemo(() => {
|
||||||
|
if (parceiroFixoSupervisor) {
|
||||||
|
const p = parceiros[0];
|
||||||
|
if (!p) return "Parceiro vinculado";
|
||||||
|
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
|
||||||
|
}
|
||||||
|
if (selectedPartnerIds === null) return "Todos os parceiros";
|
||||||
|
if (selectedPartnerIds.length === 1) {
|
||||||
|
const p = parceiros.find((x) => x.id === selectedPartnerIds[0]);
|
||||||
|
if (!p) return "1 parceiro";
|
||||||
|
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
|
||||||
|
}
|
||||||
|
return `${selectedPartnerIds.length} parceiros selecionados`;
|
||||||
|
}, [parceiroFixoSupervisor, parceiros, selectedPartnerIds]);
|
||||||
|
|
||||||
|
const toggleParceiro = useCallback(
|
||||||
|
(id: string, checked: boolean) => {
|
||||||
|
setSelectedPartnerIds((prev) => {
|
||||||
|
if (parceiroFixoSupervisor) return prev;
|
||||||
|
if (prev === null) {
|
||||||
|
if (checked) return null;
|
||||||
|
const next = allParceiroIds.filter((x) => x !== id);
|
||||||
|
if (next.length === 0) {
|
||||||
|
toast.error("Selecione ao menos um parceiro.");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
const set = new Set(prev);
|
||||||
|
if (checked) set.add(id);
|
||||||
|
else set.delete(id);
|
||||||
|
const arr = Array.from(set);
|
||||||
|
if (arr.length === 0) {
|
||||||
|
toast.error("Selecione ao menos um parceiro.");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (arr.length === allParceiroIds.length) return null;
|
||||||
|
return arr;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[allParceiroIds, parceiroFixoSupervisor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTodosParceiros = useCallback(() => {
|
||||||
|
if (!parceiroFixoSupervisor) setSelectedPartnerIds(null);
|
||||||
|
}, [parceiroFixoSupervisor]);
|
||||||
|
|
||||||
|
const isParceiroChecked = (id: string) =>
|
||||||
|
selectedPartnerIds === null || (selectedPartnerIds?.includes(id) ?? false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto max-w-7xl space-y-8 p-4 pb-14 lg:p-8">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-primary/12 via-primary/5 to-transparent blur-3xl" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className="relative flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
Intelligence Score
|
||||||
|
</div>
|
||||||
|
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight md:text-4xl">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-inner cyber-glow">
|
||||||
|
<LayoutDashboard className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-xl text-sm text-muted-foreground">
|
||||||
|
Distribuição por cliente — total da competência no período (todas as tarefas do fechamento, alinhado à
|
||||||
|
pontuação total do mês na tela de fechamento).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 self-start border-primary/20 bg-background/80 shadow-sm backdrop-blur"
|
||||||
|
onClick={() => void loadRelatorio()}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
|
Atualizar
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: 0.05 }}
|
||||||
|
>
|
||||||
|
<Card className="overflow-hidden border-primary/10 shadow-xl">
|
||||||
|
<CardHeader className="border-b border-border/60 bg-muted/30 pb-4">
|
||||||
|
<CardTitle className="text-lg">Filtros</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 pt-6 sm:grid-cols-2 sm:items-end lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.25fr)_auto] lg:gap-5">
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
||||||
|
Data inicial
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full min-w-0 justify-start gap-2 text-left text-sm font-normal tabular-nums"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="min-w-0 truncate">{format(dataInicio, "dd/MM/yyyy", { locale: ptBR })}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={dataInicio}
|
||||||
|
onSelect={(d) => {
|
||||||
|
if (d) setDataInicio(d);
|
||||||
|
}}
|
||||||
|
locale={ptBR}
|
||||||
|
defaultMonth={dataInicio}
|
||||||
|
initialFocus
|
||||||
|
className="pointer-events-auto p-3"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
||||||
|
Data final
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 w-full min-w-0 justify-start gap-2 text-left text-sm font-normal tabular-nums"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="min-w-0 truncate">{format(dataFim, "dd/MM/yyyy", { locale: ptBR })}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={dataFim}
|
||||||
|
onSelect={(d) => {
|
||||||
|
if (d) setDataFim(d);
|
||||||
|
}}
|
||||||
|
locale={ptBR}
|
||||||
|
defaultMonth={dataFim}
|
||||||
|
disabled={(date) => date < startOfDay(dataInicio)}
|
||||||
|
initialFocus
|
||||||
|
className="pointer-events-auto p-3"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!parceiroFixoSupervisor ? (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
||||||
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
||||||
|
Parceiros
|
||||||
|
</Label>
|
||||||
|
<Popover open={parceirosMenuOpen} onOpenChange={setParceirosMenuOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={parceirosMenuOpen}
|
||||||
|
className="h-11 w-full min-w-0 justify-between gap-2 font-normal"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate text-left text-sm">{parceirosSelecaoLabel}</span>
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[min(100vw-2rem,var(--radix-popover-trigger-width))] max-w-md p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 border-b border-border/60 p-3">
|
||||||
|
<span className="text-sm font-semibold">Selecionar parceiros</span>
|
||||||
|
<div className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
id="par-todos"
|
||||||
|
checked={selectedPartnerIds === null}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
if (c === true) {
|
||||||
|
setTodosParceiros();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allParceiroIds.length <= 1) return;
|
||||||
|
setSelectedPartnerIds([allParceiroIds[0]]);
|
||||||
|
toast.info("Filtro por um parceiro. Use «Todos» para voltar a exibir todos.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="par-todos" className="cursor-pointer text-sm font-medium">
|
||||||
|
Todos os parceiros
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<ScrollArea className="h-[220px]">
|
||||||
|
<div className="space-y-1 p-3">
|
||||||
|
{parceiros.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`par-${p.id}`}
|
||||||
|
checked={isParceiroChecked(p.id)}
|
||||||
|
onCheckedChange={(c) => toggleParceiro(p.id, c === true)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`par-${p.id}`} className="flex-1 cursor-pointer truncate text-sm">
|
||||||
|
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
||||||
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
||||||
|
Parceiro
|
||||||
|
</Label>
|
||||||
|
<Select disabled value={me?.parceiroId ?? ""}>
|
||||||
|
<SelectTrigger className="h-11 w-full min-w-0">
|
||||||
|
<SelectValue placeholder="Parceiro vinculado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{parceiros.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{parceiros.length === 0 && me?.parceiroId ? (
|
||||||
|
<SelectItem value={me.parceiroId}>Parceiro vinculado</SelectItem>
|
||||||
|
) : null}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end sm:col-span-2 lg:col-span-1">
|
||||||
|
<Button type="button" className="h-11 w-full sm:w-auto lg:w-full" onClick={() => void loadRelatorio()}>
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{loading && dados.length === 0 ? (
|
||||||
|
<div className="flex justify-center py-24">
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-primary/60" />
|
||||||
|
</div>
|
||||||
|
) : dados.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-16 text-center text-muted-foreground">
|
||||||
|
Nenhum dado para os filtros atuais. Ajuste o período ou os parceiros.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
{dados.map((bloco, idx) => (
|
||||||
|
<motion.div
|
||||||
|
key={bloco.parceiroId}
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, delay: Math.min(idx * 0.06, 0.35) }}
|
||||||
|
>
|
||||||
|
<Card className="h-full overflow-hidden border-border/80 bg-card/95 shadow-lg transition-shadow hover:shadow-xl">
|
||||||
|
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-primary/8 to-transparent pb-4">
|
||||||
|
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<CardTitle
|
||||||
|
className="min-w-0 max-w-full flex-1 truncate text-left text-base font-semibold leading-snug sm:text-lg"
|
||||||
|
title={bloco.parceiroNome}
|
||||||
|
>
|
||||||
|
{bloco.parceiroNome}
|
||||||
|
</CardTitle>
|
||||||
|
<span className="inline-flex w-fit shrink-0 self-start rounded-full bg-primary/15 px-3 py-1.5 text-sm font-semibold tabular-nums text-primary sm:self-center">
|
||||||
|
{formatPontos(bloco.totalParceiro)} pts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<DistribuicaoClientesDonut bloco={bloco} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ClipboardList, ExternalLink, Plus, 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
"Janeiro",
|
||||||
|
"Fevereiro",
|
||||||
|
"Março",
|
||||||
|
"Abril",
|
||||||
|
"Maio",
|
||||||
|
"Junho",
|
||||||
|
"Julho",
|
||||||
|
"Agosto",
|
||||||
|
"Setembro",
|
||||||
|
"Outubro",
|
||||||
|
"Novembro",
|
||||||
|
"Dezembro",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatCompetenciaMes(mes: number, ano: number): string {
|
||||||
|
return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLTIP_EXCLUIR_COMPETENCIA_COM_FECHAMENTOS =
|
||||||
|
"Esta competência ainda possui fechamentos. Para excluir a competência, acesse-a e exclua todos os fechamentos primeiro; só então o botão Excluir será liberado.";
|
||||||
|
|
||||||
|
export default function Fechamentos() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { me } = useAuthAccess();
|
||||||
|
const caps = me?.capabilities;
|
||||||
|
const podeCriarCompetencia = caps?.competencias.criar ?? false;
|
||||||
|
const podeExcluirCompetencia = caps?.competencias.excluir ?? false;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [allCompetencias, setAllCompetencias] = useState<CompetenciaItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [anoSelecionado, setAnoSelecionado] = useState<string>("");
|
||||||
|
const [modalAberto, setModalAberto] = useState(false);
|
||||||
|
const [mesSelecionado, setMesSelecionado] = useState<string>("");
|
||||||
|
const [anoNovo, setAnoNovo] = useState<string>("");
|
||||||
|
const [criando, setCriando] = useState(false);
|
||||||
|
const [competenciaExcluirId, setCompetenciaExcluirId] = useState<string | null>(null);
|
||||||
|
const [excluindoCompetencia, setExcluindoCompetencia] = useState(false);
|
||||||
|
|
||||||
|
const loadCompetencias = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fechamentoCompetenciasService.listarCompetencias({});
|
||||||
|
setAllCompetencias(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao carregar competências.";
|
||||||
|
toast.error(message);
|
||||||
|
setAllCompetencias([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const anosDisponiveis = useMemo(() => {
|
||||||
|
const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))];
|
||||||
|
if (!anosUnicos.includes(currentYear)) {
|
||||||
|
anosUnicos.push(currentYear);
|
||||||
|
}
|
||||||
|
return anosUnicos.sort((a, b) => b - a).map(String);
|
||||||
|
}, [allCompetencias, currentYear]);
|
||||||
|
|
||||||
|
const competenciasFiltradas = useMemo(() => {
|
||||||
|
if (!anoSelecionado) return [];
|
||||||
|
return allCompetencias
|
||||||
|
.filter((c) => c.ano === Number(anoSelecionado))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.mes - b.mes);
|
||||||
|
}, [allCompetencias, anoSelecionado]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCompetencias();
|
||||||
|
}, [loadCompetencias]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (anosDisponiveis.length > 0 && !anoSelecionado) {
|
||||||
|
const defaultAno = anosDisponiveis.includes(String(currentYear))
|
||||||
|
? String(currentYear)
|
||||||
|
: anosDisponiveis[0];
|
||||||
|
setAnoSelecionado(defaultAno);
|
||||||
|
}
|
||||||
|
}, [anosDisponiveis, anoSelecionado, currentYear]);
|
||||||
|
|
||||||
|
const handleCriarCompetencia = async () => {
|
||||||
|
const mes = Number(mesSelecionado);
|
||||||
|
const ano = Number(anoNovo);
|
||||||
|
if (!mes || !ano) {
|
||||||
|
toast.error("Selecione mês e ano.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCriando(true);
|
||||||
|
try {
|
||||||
|
const nova = await fechamentoCompetenciasService.criarCompetencia({ mes, ano });
|
||||||
|
setAllCompetencias((prev) => [...prev, nova]);
|
||||||
|
setModalAberto(false);
|
||||||
|
setMesSelecionado("");
|
||||||
|
setAnoNovo("");
|
||||||
|
toast.success("Competência criada com sucesso.");
|
||||||
|
setAnoSelecionado(String(ano));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao criar competência.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setCriando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmarExclusaoCompetencia = async () => {
|
||||||
|
if (!competenciaExcluirId || !me?.id) {
|
||||||
|
toast.error("Não foi possível identificar o usuário para excluir a competência.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExcluindoCompetencia(true);
|
||||||
|
try {
|
||||||
|
await fechamentoCompetenciasService.excluirCompetencia(competenciaExcluirId, me.id);
|
||||||
|
toast.success("Competência excluída com sucesso.");
|
||||||
|
setCompetenciaExcluirId(null);
|
||||||
|
await loadCompetencias();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao excluir competência.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setExcluindoCompetencia(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||||
|
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||||
|
<ClipboardList className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
|
Fechamentos
|
||||||
|
</h1>
|
||||||
|
<Dialog open={modalAberto} onOpenChange={setModalAberto}>
|
||||||
|
{podeCriarCompetencia ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nova Competência
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : null}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nova Competência</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 py-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">Mês</label>
|
||||||
|
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Selecione o mês" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{meses.map((m, i) => (
|
||||||
|
<SelectItem key={i + 1} value={String(i + 1)}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">Ano</label>
|
||||||
|
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Selecione o ano" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{anosDisponiveis.map((ano) => (
|
||||||
|
<SelectItem key={ano} value={ano}>
|
||||||
|
{ano}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
|
onClick={() => setModalAberto(false)}
|
||||||
|
disabled={criando}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
||||||
|
{criando ? "Criando..." : "Criar Competência"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Ano:</span>
|
||||||
|
<Select value={anoSelecionado} onValueChange={setAnoSelecionado}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{anosDisponiveis.map((ano) => (
|
||||||
|
<SelectItem key={ano} value={ano}>
|
||||||
|
{ano}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
|
{!loading && competenciasFiltradas.length === 0 ? (
|
||||||
|
<Card className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nenhuma competência encontrada</CardTitle>
|
||||||
|
<CardDescription>Não há competências cadastradas para o ano selecionado.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-[220px]">Mês</TableHead>
|
||||||
|
<TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||||
|
<TableHead className="text-center min-w-[200px]">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||||
|
Carregando competências...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
competenciasFiltradas.map((competencia) => (
|
||||||
|
<TableRow key={competencia.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{formatCompetenciaMes(competencia.mes, competencia.ano)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{competencia.quantidadeFechamentos}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
competencia.status === "concluido"
|
||||||
|
? "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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/intelligence-score/competencias/${competencia.id}`)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Acessar
|
||||||
|
</Button>
|
||||||
|
{podeExcluirCompetencia ? (
|
||||||
|
competencia.quantidadeFechamentos > 0 ? (
|
||||||
|
<span className="relative inline-flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive opacity-80"
|
||||||
|
disabled
|
||||||
|
aria-label="Excluir competência (indisponível: há fechamentos)"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
<Tooltip delayDuration={150}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 z-10 cursor-help rounded-md bg-transparent"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-left leading-snug">
|
||||||
|
{TOOLTIP_EXCLUIR_COMPETENCIA_COM_FECHAMENTOS}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
title="Excluir competência"
|
||||||
|
onClick={() => setCompetenciaExcluirId(competencia.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={competenciaExcluirId !== null} onOpenChange={(open) => !open && setCompetenciaExcluirId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Excluir competência</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Esta ação não pode ser desfeita. Só é permitida quando não há fechamentos vinculados.
|
||||||
|
{competenciaExcluirId ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Competência:{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{(() => {
|
||||||
|
const c = allCompetencias.find((x) => x.id === competenciaExcluirId);
|
||||||
|
return c ? formatCompetenciaMes(c.mes, c.ano) : "";
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
|
onClick={() => setCompetenciaExcluirId(null)}
|
||||||
|
disabled={excluindoCompetencia}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleConfirmarExclusaoCompetencia()}
|
||||||
|
disabled={excluindoCompetencia}
|
||||||
|
>
|
||||||
|
{excluindoCompetencia ? "Excluindo..." : "Confirmar exclusão"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { ExternalLink, FileSpreadsheet, FileText, Landmark, ListChecks, Loader2, Target } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
import { authMeService } from "@/services/fechamento/authMe";
|
||||||
|
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const meses = [
|
||||||
|
"Janeiro",
|
||||||
|
"Fevereiro",
|
||||||
|
"Março",
|
||||||
|
"Abril",
|
||||||
|
"Maio",
|
||||||
|
"Junho",
|
||||||
|
"Julho",
|
||||||
|
"Agosto",
|
||||||
|
"Setembro",
|
||||||
|
"Outubro",
|
||||||
|
"Novembro",
|
||||||
|
"Dezembro",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatCompetenciaMes(mes: number, ano: number): string {
|
||||||
|
return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPontos(valor: number): string {
|
||||||
|
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeuFechamento() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { me } = useAuthAccess();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [exportacao, setExportacao] = useState<{ fechamentoId: string; tipo: "planilha" | "pdf" } | null>(null);
|
||||||
|
const [anoSelecionado, setAnoSelecionado] = useState<string>(String(currentYear));
|
||||||
|
const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuFechamento>> | null>(null);
|
||||||
|
|
||||||
|
const semVinculo = !me?.parceiroId;
|
||||||
|
const competenciasOrdenadas = useMemo(() => {
|
||||||
|
if (!dados) return [];
|
||||||
|
return [...dados.competencias].sort((a, b) => a.mes - b.mes);
|
||||||
|
}, [dados]);
|
||||||
|
const anosDisponiveis = useMemo(() => {
|
||||||
|
const anos = new Set<number>([currentYear]);
|
||||||
|
for (let i = 0; i < 4; i += 1) {
|
||||||
|
anos.add(currentYear - i);
|
||||||
|
}
|
||||||
|
if (dados) {
|
||||||
|
dados.competencias.forEach((item) => anos.add(item.ano));
|
||||||
|
}
|
||||||
|
return Array.from(anos).sort((a, b) => b - a).map(String);
|
||||||
|
}, [currentYear, dados]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!anosDisponiveis.includes(anoSelecionado)) {
|
||||||
|
setAnoSelecionado(anosDisponiveis[0] ?? String(currentYear));
|
||||||
|
}
|
||||||
|
}, [anoSelecionado, anosDisponiveis, currentYear]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (semVinculo) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!me?.email) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Não foi possível identificar o usuário logado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await authMeService.getMeuFechamento(me.email, Number(anoSelecionado));
|
||||||
|
if (!cancelled) {
|
||||||
|
setDados(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao carregar meu fechamento.";
|
||||||
|
toast.error(message);
|
||||||
|
setDados(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [me?.email, semVinculo, anoSelecionado]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Carregando meu fechamento...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semVinculo) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<Card className="mx-auto max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usuário não vinculado</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Este usuário não está vinculado a um parceiro. Para consultar seu fechamento, é necessário vínculo.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
<ListChecks className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
|
Meu Fechamento
|
||||||
|
</h1>
|
||||||
|
<Button onClick={() => navigate("/intelligence-score/meu-fechamento/banco-pontos")} className="gap-2">
|
||||||
|
<Landmark className="h-4 w-4" />
|
||||||
|
Banco de Pontos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Ano:</span>
|
||||||
|
<Select value={anoSelecionado} onValueChange={setAnoSelecionado}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{anosDisponiveis.map((ano) => (
|
||||||
|
<SelectItem key={ano} value={ano}>
|
||||||
|
{ano}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<Card className="border-border/70 bg-card shadow-sm">
|
||||||
|
<CardHeader className="space-y-2 p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardDescription className="text-[11px] uppercase tracking-wide">Total pontuação paga</CardDescription>
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.totalPontos ?? 0)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border/70 bg-card shadow-sm">
|
||||||
|
<CardHeader className="space-y-2 p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardDescription className="text-[11px] uppercase tracking-wide">Saldo dos fechamentos</CardDescription>
|
||||||
|
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.saldoFechamentos ?? 0)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-border/70 bg-card shadow-sm">
|
||||||
|
<CardHeader className="space-y-2 p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<CardDescription className="text-[11px] uppercase tracking-wide">Saldo banco de pontos</CardDescription>
|
||||||
|
<Landmark className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.saldoBancoPontos ?? 0)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
|
{!dados || competenciasOrdenadas.length === 0 ? (
|
||||||
|
<Card className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||||
|
<CardDescription>Não há fechamento para o ano selecionado.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-[220px]">Competência</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">Status fechamento</TableHead>
|
||||||
|
<TableHead className="min-w-[130px] text-right">Pontuação paga</TableHead>
|
||||||
|
<TableHead className="min-w-[240px] text-center">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{competenciasOrdenadas.map((item) => (
|
||||||
|
<TableRow key={item.competenciaId}>
|
||||||
|
<TableCell className="font-medium">{formatCompetenciaMes(item.mes, item.ano)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.fechamento ? (
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
item.fechamento.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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.fechamento.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{item.fechamento?.status === "fechado"
|
||||||
|
? formatPontos(Number(item.fechamento.pontuacaoPaga))
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={
|
||||||
|
!item.fechamento ||
|
||||||
|
item.fechamento.status !== "fechado" ||
|
||||||
|
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha") ||
|
||||||
|
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf")
|
||||||
|
}
|
||||||
|
onClick={() => item.fechamento && void handleExportar(item.fechamento.id)}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
{exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha"
|
||||||
|
? "Exportando..."
|
||||||
|
: "Excel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={
|
||||||
|
!item.fechamento ||
|
||||||
|
item.fechamento.status !== "fechado" ||
|
||||||
|
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha") ||
|
||||||
|
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf")
|
||||||
|
}
|
||||||
|
onClick={() => item.fechamento && void handleExportarPdf(item.fechamento.id)}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
{exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf"
|
||||||
|
? "Exportando..."
|
||||||
|
: "PDF"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!item.fechamento}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/intelligence-score/fechamentos/${item.fechamento?.id}`, {
|
||||||
|
state: {
|
||||||
|
competenciaId: item.competenciaId,
|
||||||
|
status: item.fechamento?.status,
|
||||||
|
readonlyView: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
|
Detalhes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Landmark, Loader2 } 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 { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
import { authMeService } from "@/services/fechamento/authMe";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nomeExibicao(nome: string, codinome: string | null): string {
|
||||||
|
return codinome?.trim() ? `${nome} (${codinome.trim()})` : nome;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeuFechamentoBancoPontos() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { me } = useAuthAccess();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [exportando, setExportando] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuBancoPontosExtrato>> | null>(null);
|
||||||
|
const semVinculo = !me?.parceiroId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (semVinculo) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!me?.email) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Não foi possível identificar o usuário logado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await authMeService.getMeuBancoPontosExtrato(me.email, page, PER_PAGE);
|
||||||
|
if (!cancelled) {
|
||||||
|
setDados(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao carregar extrato.";
|
||||||
|
toast.error(message);
|
||||||
|
setDados(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [me?.email, page, semVinculo]);
|
||||||
|
|
||||||
|
const handleExportar = async () => {
|
||||||
|
if (!me?.email) return;
|
||||||
|
try {
|
||||||
|
setExportando(true);
|
||||||
|
const { buffer, filename } = await authMeService.exportarMeuBancoPontosExtrato(me.email);
|
||||||
|
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 ?? "meu-banco-pontos.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 extrato.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setExportando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (semVinculo) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/meu-fechamento")}>
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
|
Voltar
|
||||||
|
</Button>
|
||||||
|
<div className="mt-4 rounded-lg border p-4 text-sm text-muted-foreground">
|
||||||
|
Usuário não está vinculado a parceiro.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPaginas = Math.max(1, dados?.meta.totalPaginas ?? 1);
|
||||||
|
const parceiroNome =
|
||||||
|
dados?.parceiro ? nomeExibicao(dados.parceiro.nome, dados.parceiro.codinome) : "Carregando...";
|
||||||
|
|
||||||
|
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/meu-fechamento")}>
|
||||||
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
|
Voltar ao meu fechamento
|
||||||
|
</Button>
|
||||||
|
</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 — {parceiroNome}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Extrato detalhado do seu banco de pontos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 p-3 md:p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
{dados?.parceiro ? (
|
||||||
|
<span className={`text-xl font-bold ${(dados.parceiro.saldo ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||||
|
Saldo atual: {formatPontos(Number(dados.parceiro.saldo ?? 0))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={exportando || loading} onClick={() => void handleExportar()}>
|
||||||
|
{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</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} 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>
|
||||||
|
) : !dados || dados.data.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
|
||||||
|
Nenhuma movimentação registrada.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
dados.data.map((linha) => {
|
||||||
|
const isCredito = linha.tipo === "credito";
|
||||||
|
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">
|
||||||
|
Crédito
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50">
|
||||||
|
Débito
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={`text-right font-semibold tabular-nums ${isCredito ? "text-emerald-600" : "text-red-600"}`}>
|
||||||
|
{isCredito ? "+" : "−"}
|
||||||
|
{formatPontos(Number(linha.quantidade))}
|
||||||
|
</TableCell>
|
||||||
|
</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">
|
||||||
|
Página {dados?.meta.paginaAtual ?? 1} de {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 >= totalPaginas}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPaginas, p + 1))}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
|
||||||
|
import { Image as ImageIcon, Loader2, Upload, UserCircle2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
import {
|
||||||
|
authMeService,
|
||||||
|
type MeuParceiroPerfilData,
|
||||||
|
} from "@/services/fechamento/authMe";
|
||||||
|
import { fechamentoUploadsService } from "@/services/fechamento/uploads";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||||
|
|
||||||
|
type PerfilForm = {
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
whatsapp: string;
|
||||||
|
tipoPessoa: "fisica" | "juridica";
|
||||||
|
cpf: string;
|
||||||
|
cnpj: string;
|
||||||
|
logoUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORM_INICIAL: PerfilForm = {
|
||||||
|
nome: "",
|
||||||
|
email: "",
|
||||||
|
whatsapp: "",
|
||||||
|
tipoPessoa: "juridica",
|
||||||
|
cpf: "",
|
||||||
|
cnpj: "",
|
||||||
|
logoUrl: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
function onlyDigits(value: string): string {
|
||||||
|
return value.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCpf(value: string): string {
|
||||||
|
const digits = onlyDigits(value).slice(0, 11);
|
||||||
|
const p1 = digits.slice(0, 3);
|
||||||
|
const p2 = digits.slice(3, 6);
|
||||||
|
const p3 = digits.slice(6, 9);
|
||||||
|
const p4 = digits.slice(9, 11);
|
||||||
|
if (digits.length <= 3) return p1;
|
||||||
|
if (digits.length <= 6) return `${p1}.${p2}`;
|
||||||
|
if (digits.length <= 9) return `${p1}.${p2}.${p3}`;
|
||||||
|
return `${p1}.${p2}.${p3}-${p4}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCnpj(value: string): string {
|
||||||
|
const digits = onlyDigits(value).slice(0, 14);
|
||||||
|
const p1 = digits.slice(0, 2);
|
||||||
|
const p2 = digits.slice(2, 5);
|
||||||
|
const p3 = digits.slice(5, 8);
|
||||||
|
const p4 = digits.slice(8, 12);
|
||||||
|
const p5 = digits.slice(12, 14);
|
||||||
|
if (digits.length <= 2) return p1;
|
||||||
|
if (digits.length <= 5) return `${p1}.${p2}`;
|
||||||
|
if (digits.length <= 8) return `${p1}.${p2}.${p3}`;
|
||||||
|
if (digits.length <= 12) return `${p1}.${p2}.${p3}/${p4}`;
|
||||||
|
return `${p1}.${p2}.${p3}/${p4}-${p5}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWhatsapp(value: string): string {
|
||||||
|
const digits = onlyDigits(value).slice(0, 11);
|
||||||
|
const ddd = digits.slice(0, 2);
|
||||||
|
const part1 = digits.slice(2, 7);
|
||||||
|
const part2 = digits.slice(7, 11);
|
||||||
|
if (digits.length <= 2) return ddd;
|
||||||
|
if (digits.length <= 7) return `${ddd} ${part1}`;
|
||||||
|
return `${ddd} ${part1}-${part2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalText(value: string): string | null {
|
||||||
|
const normalized = value.trim();
|
||||||
|
return normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLogoFile(file: File): string | null {
|
||||||
|
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||||
|
return "Arquivo inválido. Use JPG, PNG ou WEBP.";
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return "Arquivo excede 5MB. Escolha uma imagem menor.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPerfilToForm(perfil: MeuParceiroPerfilData): PerfilForm {
|
||||||
|
return {
|
||||||
|
nome: perfil.nome ?? "",
|
||||||
|
email: perfil.email ?? "",
|
||||||
|
whatsapp: onlyDigits(perfil.whatsapp ?? ""),
|
||||||
|
tipoPessoa: perfil.tipoPessoa,
|
||||||
|
cpf: onlyDigits(perfil.cpf ?? ""),
|
||||||
|
cnpj: onlyDigits(perfil.cnpj ?? ""),
|
||||||
|
logoUrl: perfil.logoUrl ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeuPerfil() {
|
||||||
|
const { me } = useAuthAccess();
|
||||||
|
const logoFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const [perfil, setPerfil] = useState<MeuParceiroPerfilData | null>(null);
|
||||||
|
const [form, setForm] = useState<PerfilForm>(FORM_INICIAL);
|
||||||
|
const [selectedLogoFile, setSelectedLogoFile] = useState<File | null>(null);
|
||||||
|
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const semVinculo = !me?.parceiroId;
|
||||||
|
const tipoPessoa = form.tipoPessoa;
|
||||||
|
const isPessoaFisica = tipoPessoa === "fisica";
|
||||||
|
const logoDisplayUrl = logoPreviewUrl ?? normalizeOptionalText(form.logoUrl);
|
||||||
|
|
||||||
|
const logoStatusLabel = useMemo(() => {
|
||||||
|
if (uploadingLogo) return "Enviando foto...";
|
||||||
|
if (selectedLogoFile) return `Arquivo selecionado: ${selectedLogoFile.name}`;
|
||||||
|
return "Nenhum arquivo novo selecionado.";
|
||||||
|
}, [selectedLogoFile, uploadingLogo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [logoPreviewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (semVinculo) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!me?.email) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Não foi possível identificar o e-mail do usuário logado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const carregarPerfil = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await authMeService.getMeuParceiroPerfil(me.email);
|
||||||
|
if (cancelled) return;
|
||||||
|
setPerfil(data);
|
||||||
|
setForm(mapPerfilToForm(data));
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao carregar perfil.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void carregarPerfil();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [me?.email, semVinculo]);
|
||||||
|
|
||||||
|
const handleLogoFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const validationMessage = validateLogoFile(file);
|
||||||
|
if (validationMessage) {
|
||||||
|
toast.error(validationMessage);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
setSelectedLogoFile(file);
|
||||||
|
setLogoPreviewUrl(URL.createObjectURL(file));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLogoSelection = () => {
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
setSelectedLogoFile(null);
|
||||||
|
setLogoPreviewUrl(null);
|
||||||
|
if (logoFileInputRef.current) {
|
||||||
|
logoFileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setForm((prev) => ({ ...prev, logoUrl: "" }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
if (!form.nome.trim()) {
|
||||||
|
toast.error("Nome é obrigatório.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.email.trim()) {
|
||||||
|
toast.error("E-mail é obrigatório.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isPessoaFisica && onlyDigits(form.cpf).length !== 11) {
|
||||||
|
toast.error("CPF deve conter 11 dígitos.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isPessoaFisica && onlyDigits(form.cnpj).length !== 14) {
|
||||||
|
toast.error("CNPJ deve conter 14 dígitos.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadLogoIfNeeded = async (): Promise<string | null> => {
|
||||||
|
if (!selectedLogoFile) {
|
||||||
|
return normalizeOptionalText(form.logoUrl);
|
||||||
|
}
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const signed = await fechamentoUploadsService.presignUploadParceiroLogo({
|
||||||
|
fileName: selectedLogoFile.name,
|
||||||
|
contentType: selectedLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
|
||||||
|
});
|
||||||
|
await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedLogoFile);
|
||||||
|
return signed.publicUrl;
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSalvar = async () => {
|
||||||
|
if (!me?.email || !perfil) return;
|
||||||
|
if (!validateForm()) return;
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const logoUrl = await uploadLogoIfNeeded();
|
||||||
|
const atualizado = await authMeService.patchMeuParceiroPerfil(me.email, {
|
||||||
|
nome: form.nome.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
whatsapp: normalizeOptionalText(formatWhatsapp(form.whatsapp)),
|
||||||
|
logoUrl,
|
||||||
|
cpf: isPessoaFisica ? onlyDigits(form.cpf) : null,
|
||||||
|
cnpj: !isPessoaFisica ? onlyDigits(form.cnpj) : null,
|
||||||
|
});
|
||||||
|
setPerfil(atualizado);
|
||||||
|
setForm(mapPerfilToForm(atualizado));
|
||||||
|
if (logoPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(logoPreviewUrl);
|
||||||
|
}
|
||||||
|
setLogoPreviewUrl(null);
|
||||||
|
setSelectedLogoFile(null);
|
||||||
|
toast.success("Perfil atualizado com sucesso.");
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao atualizar perfil.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Carregando perfil...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semVinculo) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<Card className="mx-auto max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usuário não vinculado</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Este usuário não está vinculado a um parceiro. Para editar o perfil, é necessário vínculo com parceiro.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!perfil) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 md:p-6">
|
||||||
|
<Card className="mx-auto max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Não foi possível carregar o perfil</CardTitle>
|
||||||
|
<CardDescription>Tente atualizar a página novamente.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||||
|
<UserCircle2 className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
|
Meu Perfil
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Atualize seus dados de contato e identificação.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
|
<Card className="mx-auto max-w-4xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dados do parceiro vinculado</CardTitle>
|
||||||
|
<CardDescription>Você pode editar apenas os campos permitidos para autoatendimento.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex flex-col items-center gap-3 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative h-24 w-24 overflow-hidden rounded-full border shadow-sm transition hover:brightness-95"
|
||||||
|
onClick={() => logoFileInputRef.current?.click()}
|
||||||
|
title="Clique para alterar a foto"
|
||||||
|
>
|
||||||
|
{logoDisplayUrl ? (
|
||||||
|
<img src={logoDisplayUrl} alt="Foto de perfil" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||||
|
<ImageIcon className="h-7 w-7" />
|
||||||
|
</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" />
|
||||||
|
Alterar foto
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={logoFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||||||
|
onChange={handleLogoFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">{logoStatusLabel}</p>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={clearLogoSelection} disabled={uploadingLogo}>
|
||||||
|
Remover foto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="perfil-nome">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="perfil-nome"
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perfil-email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="perfil-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perfil-whatsapp">WhatsApp</Label>
|
||||||
|
<Input
|
||||||
|
id="perfil-whatsapp"
|
||||||
|
value={formatWhatsapp(form.whatsapp)}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))}
|
||||||
|
placeholder="11 99999-9999"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Tipo de Pessoa</Label>
|
||||||
|
<Select
|
||||||
|
value={form.tipoPessoa}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tipoPessoa: value as "fisica" | "juridica",
|
||||||
|
cpf: value === "fisica" ? prev.cpf : "",
|
||||||
|
cnpj: value === "juridica" ? prev.cnpj : "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="juridica">Jurídica</SelectItem>
|
||||||
|
<SelectItem value="fisica">Física</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPessoaFisica ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perfil-cpf">CPF</Label>
|
||||||
|
<Input
|
||||||
|
id="perfil-cpf"
|
||||||
|
value={formatCpf(form.cpf)}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))}
|
||||||
|
placeholder="000.000.000-00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="perfil-cnpj">CNPJ</Label>
|
||||||
|
<Input
|
||||||
|
id="perfil-cnpj"
|
||||||
|
value={formatCnpj(form.cnpj)}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))}
|
||||||
|
placeholder="00.000.000/0000-00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => void handleSalvar()} disabled={saving || uploadingLogo}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Salvando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Salvar alterações"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { papel } = useAuthAccess();
|
||||||
|
const homeHref =
|
||||||
|
papel === "admin" || papel === "supervisor"
|
||||||
|
? "/intelligence-score/dashboard"
|
||||||
|
: "/intelligence-score/meu-fechamento";
|
||||||
|
const homeLabel =
|
||||||
|
papel === "admin" || papel === "supervisor" ? "Voltar ao início" : "Voltar ao meu fechamento";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error(
|
||||||
|
"404 Error: User attempted to access non-existent fechamento route:",
|
||||||
|
location.pathname,
|
||||||
|
);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[70vh] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-3 text-4xl font-bold">404</h1>
|
||||||
|
<p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
|
||||||
|
<Link to={homeHref} className="text-primary underline hover:text-primary/90">
|
||||||
|
{homeLabel}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,860 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Edit, Plus, Power, Trash2, Users, UserCheck, UserX } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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 {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
fechamentoUsuariosService,
|
||||||
|
type UsuarioItem,
|
||||||
|
type UsuarioPapel,
|
||||||
|
type UsuarioStatusFiltro,
|
||||||
|
} from "@/services/fechamento/usuarios";
|
||||||
|
import {
|
||||||
|
fechamentoParceirosService,
|
||||||
|
type ParceiroItem,
|
||||||
|
} from "@/services/fechamento/parceiros";
|
||||||
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
|
||||||
|
function labelUsuarioPapel(papel: UsuarioPapel): string {
|
||||||
|
if (papel === "admin") return "Admin";
|
||||||
|
if (papel === "supervisor") return "Supervisor";
|
||||||
|
return "Parceiro";
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsuarioForm = {
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
papel: UsuarioPapel;
|
||||||
|
parceiroId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formInicial: UsuarioForm = {
|
||||||
|
nome: "",
|
||||||
|
email: "",
|
||||||
|
papel: "admin",
|
||||||
|
parceiroId: "",
|
||||||
|
};
|
||||||
|
const parceiroSemVinculoValue = "__none__";
|
||||||
|
|
||||||
|
function getNomeUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||||
|
if (!usuario) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const nome = usuario.nome?.trim();
|
||||||
|
return nome && nome.length > 0 ? nome : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmailUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||||
|
if (!usuario) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
const email = usuario.email?.trim();
|
||||||
|
return email && email.length > 0 ? email : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Usuarios() {
|
||||||
|
const { me } = useAuthAccess();
|
||||||
|
const podeEditarUsuarios = me?.capabilities.usuarios.criarEditarToggle ?? false;
|
||||||
|
const podeExcluirUsuario = me?.capabilities.usuarios.excluir ?? false;
|
||||||
|
const [usuarios, setUsuarios] = useState<UsuarioItem[]>([]);
|
||||||
|
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
||||||
|
|
||||||
|
const [loadingList, setLoadingList] = useState(true);
|
||||||
|
const [loadingParceiros, setLoadingParceiros] = useState(true);
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
const [isToggleOpen, setIsToggleOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const [selectedUsuario, setSelectedUsuario] = useState<UsuarioItem | null>(null);
|
||||||
|
const [form, setForm] = useState<UsuarioForm>(formInicial);
|
||||||
|
|
||||||
|
const [busca, setBusca] = useState("");
|
||||||
|
const [filtroPapel, setFiltroPapel] = useState<"all" | UsuarioPapel>("all");
|
||||||
|
const [filtroStatus, setFiltroStatus] = useState<UsuarioStatusFiltro>("all");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||||
|
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||||
|
const [reloadNonce, setReloadNonce] = useState(0);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, totalPaginas);
|
||||||
|
const hasActiveFilters =
|
||||||
|
busca.trim().length > 0 || filtroPapel !== "all" || filtroStatus !== "all";
|
||||||
|
const parceiroObrigatorio = form.papel === "parceiro";
|
||||||
|
const parceiroSelecionado = useMemo(
|
||||||
|
() => parceiros.find((p) => p.id === form.parceiroId) ?? null,
|
||||||
|
[parceiros, form.parceiroId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingParceiros(true);
|
||||||
|
fechamentoParceirosService
|
||||||
|
.listarParceirosAtivos()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setParceiros(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao listar parceiros.";
|
||||||
|
toast.error(message);
|
||||||
|
setParceiros([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingParceiros(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingList(true);
|
||||||
|
fechamentoUsuariosService
|
||||||
|
.listarUsuarios({
|
||||||
|
busca: busca.trim() || undefined,
|
||||||
|
papel: filtroPapel === "all" ? undefined : filtroPapel,
|
||||||
|
estaAtivo: filtroStatus,
|
||||||
|
page: currentPage,
|
||||||
|
perPage: itemsPerPage,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUsuarios(res.data ?? []);
|
||||||
|
setTotalRegistros(res.meta?.total ?? 0);
|
||||||
|
setTotalPaginas(res.meta?.totalPaginas ?? 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao listar usuários.";
|
||||||
|
toast.error(message);
|
||||||
|
setUsuarios([]);
|
||||||
|
setTotalRegistros(0);
|
||||||
|
setTotalPaginas(1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [busca, filtroPapel, filtroStatus, currentPage, itemsPerPage, reloadNonce]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [busca, filtroPapel, filtroStatus]);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setBusca("");
|
||||||
|
setFiltroPapel("all");
|
||||||
|
setFiltroStatus("all");
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
setForm(formInicial);
|
||||||
|
setIsCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (usuario: UsuarioItem) => {
|
||||||
|
setSelectedUsuario(usuario);
|
||||||
|
setForm({
|
||||||
|
nome: usuario.nome ?? "",
|
||||||
|
email: usuario.email ?? "",
|
||||||
|
papel: usuario.papel,
|
||||||
|
parceiroId: usuario.parceiroId ?? "",
|
||||||
|
});
|
||||||
|
setIsEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openToggleDialog = (usuario: UsuarioItem) => {
|
||||||
|
setSelectedUsuario(usuario);
|
||||||
|
setIsToggleOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
if (!form.nome.trim()) {
|
||||||
|
toast.error("Nome é obrigatório.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.email.trim()) {
|
||||||
|
toast.error("E-mail é obrigatório.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (parceiroObrigatorio && !form.parceiroId) {
|
||||||
|
toast.error("Selecione um parceiro para o perfil parceiro.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await fechamentoUsuariosService.criarUsuario({
|
||||||
|
nome: form.nome.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
papel: form.papel,
|
||||||
|
parceiroId: form.parceiroId || null,
|
||||||
|
estaAtivo: true,
|
||||||
|
});
|
||||||
|
toast.success("Usuário criado com sucesso.");
|
||||||
|
setIsCreateOpen(false);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setReloadNonce((prev) => prev + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao criar usuário.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = async () => {
|
||||||
|
if (!selectedUsuario || !validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await fechamentoUsuariosService.editarUsuario(selectedUsuario.id, {
|
||||||
|
nome: form.nome.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
papel: form.papel,
|
||||||
|
parceiroId: form.parceiroId || null,
|
||||||
|
});
|
||||||
|
toast.success("Usuário atualizado com sucesso.");
|
||||||
|
setIsEditOpen(false);
|
||||||
|
setSelectedUsuario(null);
|
||||||
|
setReloadNonce((prev) => prev + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao editar usuário.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeleteDialog = (usuario: UsuarioItem) => {
|
||||||
|
setSelectedUsuario(usuario);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUsuario = async () => {
|
||||||
|
if (!selectedUsuario) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
await fechamentoUsuariosService.excluirUsuario(selectedUsuario.id);
|
||||||
|
toast.success("Usuário excluído com sucesso.");
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
setSelectedUsuario(null);
|
||||||
|
setReloadNonce((prev) => prev + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao excluir usuário.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async () => {
|
||||||
|
if (!selectedUsuario) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setToggling(true);
|
||||||
|
const updated = await fechamentoUsuariosService.toggleAtivoUsuario(selectedUsuario.id);
|
||||||
|
toast.success(updated.estaAtivo ? "Usuário reativado com sucesso." : "Usuário inativado com sucesso.");
|
||||||
|
setIsToggleOpen(false);
|
||||||
|
setSelectedUsuario(null);
|
||||||
|
setReloadNonce((prev) => prev + 1);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Erro ao atualizar status do usuário.";
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsPerPageChange = (value: string) => {
|
||||||
|
setItemsPerPage(Number(value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangePapel = (value: UsuarioPapel) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
papel: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||||
|
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||||
|
<Users className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
|
Usuários
|
||||||
|
</h1>
|
||||||
|
{podeEditarUsuarios ? (
|
||||||
|
<Button onClick={openCreateDialog} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Novo usuário
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por nome ou e-mail..."
|
||||||
|
value={busca}
|
||||||
|
onChange={(e) => setBusca(e.target.value)}
|
||||||
|
className="w-full md:max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filtroPapel}
|
||||||
|
onValueChange={(value) => setFiltroPapel(value as "all" | UsuarioPapel)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full md:w-[170px]">
|
||||||
|
<SelectValue placeholder="Perfil" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="supervisor">Supervisor</SelectItem>
|
||||||
|
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filtroStatus} onValueChange={(value) => setFiltroStatus(value as UsuarioStatusFiltro)}>
|
||||||
|
<SelectTrigger className="w-full md:w-[170px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="true">Ativos</SelectItem>
|
||||||
|
<SelectItem value="false">Inativos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-sm text-muted-foreground">Itens:</span>
|
||||||
|
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="20">20</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
|
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
|
||||||
|
<Card className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Nenhum usuário cadastrado
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Crie o primeiro usuário para iniciar o gerenciamento de acessos.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{podeEditarUsuarios ? (
|
||||||
|
<Button onClick={openCreateDialog}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Novo usuário
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">Não há usuários cadastrados nesta unidade.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-[220px]">Nome</TableHead>
|
||||||
|
<TableHead className="min-w-[220px]">E-mail</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">Perfil</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||||
|
<TableHead className="text-center">Ações</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingList ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||||
|
Carregando...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : totalRegistros === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||||
|
Nenhum usuário encontrado para os filtros atuais.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
usuarios.map((usuario) => (
|
||||||
|
<TableRow key={usuario.id}>
|
||||||
|
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
||||||
|
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
usuario.papel === "admin"
|
||||||
|
? "border-blue-600/30 bg-blue-50 font-normal text-blue-800 hover:bg-blue-50 dark:bg-blue-950/40 dark:text-blue-200"
|
||||||
|
: usuario.papel === "supervisor"
|
||||||
|
? "border-violet-600/30 bg-violet-50 font-normal text-violet-800 hover:bg-violet-50 dark:bg-violet-950/40 dark:text-violet-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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{labelUsuarioPapel(usuario.papel)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
usuario.estaAtivo
|
||||||
|
? "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-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50 dark:bg-red-950/40 dark:text-red-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{usuario.estaAtivo ? "Ativo" : "Inativo"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{podeEditarUsuarios || podeExcluirUsuario ? (
|
||||||
|
<>
|
||||||
|
{podeEditarUsuarios ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEditDialog(usuario)}
|
||||||
|
title="Editar usuário"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openToggleDialog(usuario)}
|
||||||
|
title={usuario.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{podeExcluirUsuario ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openDeleteDialog(usuario)}
|
||||||
|
title="Excluir usuário"
|
||||||
|
disabled={me?.id === usuario.id}
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Somente leitura</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loadingList && totalRegistros > 0 && (
|
||||||
|
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center">
|
||||||
|
<p className="text-center text-sm text-muted-foreground sm:text-left">
|
||||||
|
Mostrando {totalRegistros} {totalRegistros === 1 ? "usuário" : "usuários"}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</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="h-9 w-10 p-0"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Próxima
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Novo usuário</DialogTitle>
|
||||||
|
<DialogDescription>Preencha os dados para criar um novo usuário.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="create-nome">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="create-nome"
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||||
|
placeholder="Nome completo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="create-email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="create-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Perfil</Label>
|
||||||
|
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="supervisor">Supervisor</SelectItem>
|
||||||
|
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={form.parceiroId || parceiroSemVinculoValue}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={loadingParceiros}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
parceiroObrigatorio
|
||||||
|
? "Selecione um parceiro"
|
||||||
|
: "Opcional para admin, obrigatório para parceiro"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||||
|
Sem parceiro
|
||||||
|
</SelectItem>
|
||||||
|
{parceiros.map((parceiro) => (
|
||||||
|
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||||
|
{parceiro.codinome?.trim()
|
||||||
|
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||||
|
: parceiro.nome}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
|
onClick={() => setIsCreateOpen(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={saving}>
|
||||||
|
{saving ? "Criando..." : "Criar usuário"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar usuário</DialogTitle>
|
||||||
|
<DialogDescription>Atualize os dados do usuário selecionado.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-nome">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-nome"
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||||
|
placeholder="Nome completo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-email">E-mail</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Perfil</Label>
|
||||||
|
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="supervisor">Supervisor</SelectItem>
|
||||||
|
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={form.parceiroId || parceiroSemVinculoValue}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={loadingParceiros}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
parceiroObrigatorio
|
||||||
|
? "Selecione um parceiro"
|
||||||
|
: "Opcional para admin, obrigatório para parceiro"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||||
|
Sem parceiro
|
||||||
|
</SelectItem>
|
||||||
|
{parceiros.map((parceiro) => (
|
||||||
|
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||||
|
{parceiro.codinome?.trim()
|
||||||
|
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||||
|
: parceiro.nome}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{parceiroSelecionado && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Vinculado: {parceiroSelecionado.codinome?.trim() || parceiroSelecionado.nome}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
|
onClick={() => setIsEditOpen(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEdit} disabled={saving}>
|
||||||
|
{saving ? "Salvando..." : "Salvar alterações"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Excluir usuário</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-2">
|
||||||
|
<span className="block">
|
||||||
|
Deseja excluir o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong> (
|
||||||
|
{getEmailUsuario(selectedUsuario)})?
|
||||||
|
</span>
|
||||||
|
<span className="block">
|
||||||
|
O usuário deixará de aparecer na lista e não poderá mais acessar o sistema. Se houver parceiro
|
||||||
|
vinculado, apenas o vínculo será removido — os dados do parceiro e dos fechamentos permanecem
|
||||||
|
intactos.
|
||||||
|
</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={deleting}>
|
||||||
|
Cancelar
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteUsuario}
|
||||||
|
disabled={deleting || !selectedUsuario || me?.id === selectedUsuario?.id}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleting ? "Excluindo..." : "Excluir usuário"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog open={isToggleOpen} onOpenChange={setIsToggleOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{selectedUsuario?.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{selectedUsuario?.estaAtivo ? (
|
||||||
|
<>
|
||||||
|
Deseja inativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Deseja reativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={toggling}>
|
||||||
|
Cancelar
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
|
||||||
|
{toggling ? (
|
||||||
|
"Processando..."
|
||||||
|
) : selectedUsuario?.estaAtivo ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<UserX className="h-4 w-4" />
|
||||||
|
Inativar
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<UserCheck className="h-4 w-4" />
|
||||||
|
Reativar
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Layout } from "@/components/Layout";
|
|
||||||
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 { BotView } from "@/components/bots/BotView";
|
|
||||||
import { BotChat } from "@/components/bots/BotChat";
|
|
||||||
import { AgentView } from "@/components/agent/AgentView";
|
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
|
||||||
import React from "react";
|
|
||||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
|
||||||
|
|
||||||
interface Bot {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
prompt: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Index = () => {
|
|
||||||
const [activeView, setActiveView] = useState<"chat" | "images" | "transcription" | "generation" | "bots" | "agent">("chat");
|
|
||||||
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
|
||||||
|
|
||||||
const handleStartBotChat = (bot: Bot) => {
|
|
||||||
setActiveBotChat(bot);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackFromBotChat = () => {
|
|
||||||
setActiveBotChat(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if(!GlobalFunctions.isUsuarioLogado())window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
|
||||||
},[]);
|
|
||||||
|
|
||||||
return (<Routes>
|
|
||||||
<Route path="" element={<Navigate to={`/404`} replace />} />
|
|
||||||
<Route path="codex">
|
|
||||||
<Route path="" element={
|
|
||||||
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
|
||||||
{activeView === "chat" && <ChatView />}
|
|
||||||
{activeView === "images" && <ImageView />}
|
|
||||||
{activeView === "transcription" && <TranscriptionView />}
|
|
||||||
{activeView === "generation" && <GenerationView />}
|
|
||||||
{activeView === "bots" && (
|
|
||||||
activeBotChat ? (
|
|
||||||
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
|
||||||
) : (
|
|
||||||
<BotView onStartChat={handleStartBotChat} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{activeView === "agent" && <AgentView />}
|
|
||||||
</Layout>
|
|
||||||
} />
|
|
||||||
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<Navigate to={`/404`} replace />} />
|
|
||||||
</Routes>
|
|
||||||
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Index;
|
|
||||||
+84
-262
@@ -1,9 +1,6 @@
|
|||||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
import { apiService } from './api';
|
import { apiService } from './api';
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para resposta do POST de parecer técnico
|
|
||||||
*/
|
|
||||||
export interface CreateOpinionResponse {
|
export interface CreateOpinionResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -11,9 +8,6 @@ export interface CreateOpinionResponse {
|
|||||||
file_url_melhoria?: string;
|
file_url_melhoria?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para requisição de criação de parecer
|
|
||||||
*/
|
|
||||||
export interface CreateOpinionRequest {
|
export interface CreateOpinionRequest {
|
||||||
titulo: string;
|
titulo: string;
|
||||||
categoria: string;
|
categoria: string;
|
||||||
@@ -22,14 +16,8 @@ export interface CreateOpinionRequest {
|
|||||||
estabelecimentoId?: number;
|
estabelecimentoId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Status do parecer
|
|
||||||
*/
|
|
||||||
export type OpinionStatus = 'processando' | 'concluido' | 'erro';
|
export type OpinionStatus = 'processando' | 'concluido' | 'erro';
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para um parecer retornado pela API
|
|
||||||
*/
|
|
||||||
export interface OpinionRecord {
|
export interface OpinionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
estabelecimento_id: number;
|
estabelecimento_id: number;
|
||||||
@@ -40,13 +28,10 @@ export interface OpinionRecord {
|
|||||||
file_url: string;
|
file_url: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
file_url_melhoria: string;
|
file_url_melhoria: string;
|
||||||
status?: OpinionStatus; // Status do processamento do parecer (pode vir da API ou ser local)
|
status?: OpinionStatus;
|
||||||
isLocalPending?: boolean; // Flag para indicar se é um registro local temporário
|
isLocalPending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para parâmetros de paginação e busca
|
|
||||||
*/
|
|
||||||
export interface GetOpinionsParams {
|
export interface GetOpinionsParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
@@ -55,9 +40,6 @@ export interface GetOpinionsParams {
|
|||||||
estabelecimentoId?: number;
|
estabelecimentoId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para resposta do GET de pareceres
|
|
||||||
*/
|
|
||||||
export interface GetOpinionsResponse {
|
export interface GetOpinionsResponse {
|
||||||
data: OpinionRecord[];
|
data: OpinionRecord[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -65,74 +47,53 @@ export interface GetOpinionsResponse {
|
|||||||
per_page: number;
|
per_page: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type ApiErrorShape = { message?: string; status?: number };
|
||||||
* Serviço para gerenciamento de pareceres jurídicos
|
|
||||||
*/
|
|
||||||
class AgentService {
|
class AgentService {
|
||||||
private readonly CREATE_OPINION_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
|
private readonly CREATE_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
|
||||||
private readonly GET_OPINIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_parecer';
|
private readonly OPINIONS_BASE = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex';
|
||||||
|
|
||||||
/**
|
private resolveUserContext(userEmail?: string, estabelecimentoId?: number) {
|
||||||
* Cria um novo parecer técnico
|
|
||||||
*
|
|
||||||
* @param request - Dados do parecer
|
|
||||||
* @returns Promise com a resposta da API
|
|
||||||
*/
|
|
||||||
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
|
|
||||||
const {
|
|
||||||
titulo,
|
|
||||||
categoria,
|
|
||||||
instrucoes,
|
|
||||||
userEmail,
|
|
||||||
estabelecimentoId
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
|
||||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||||
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||||
|
return { email, estabId };
|
||||||
|
}
|
||||||
|
|
||||||
// Validações
|
private assertUserContext(email: unknown, estabId: unknown) {
|
||||||
if (!titulo || titulo.trim().length === 0) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Título do parecer é obrigatório',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instrucoes || instrucoes.trim().length === 0) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Instruções são obrigatórias',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw {
|
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||||
success: false,
|
|
||||||
message: 'Email do usuário não fornecido',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!estabId) {
|
if (!estabId) {
|
||||||
throw {
|
throw { success: false, message: 'ID do estabelecimento não fornecido' };
|
||||||
success: false,
|
}
|
||||||
message: 'ID do estabelecimento não fornecido',
|
}
|
||||||
};
|
|
||||||
|
private toApiError(error: unknown): never {
|
||||||
|
const e = error as ApiErrorShape;
|
||||||
|
throw {
|
||||||
|
success: false,
|
||||||
|
message: e?.message || 'Erro desconhecido',
|
||||||
|
status: e?.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
|
||||||
|
const { titulo, categoria, instrucoes, userEmail, estabelecimentoId } = request;
|
||||||
|
|
||||||
|
if (!titulo?.trim()) {
|
||||||
|
throw { success: false, message: 'Título do parecer é obrigatório' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log para debug
|
if (!instrucoes?.trim()) {
|
||||||
console.log('Criando parecer:', {
|
throw { success: false, message: 'Instruções são obrigatórias' };
|
||||||
titulo,
|
}
|
||||||
categoria,
|
|
||||||
instrucoesLength: instrucoes.length,
|
const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
|
||||||
userEmail: email,
|
this.assertUserContext(email, estabId);
|
||||||
estabelecimentoId: estabId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Faz a requisição usando o serviço de API com timeout de 5 minutos
|
|
||||||
const response = await apiService.post<CreateOpinionResponse>(
|
const response = await apiService.post<CreateOpinionResponse>(
|
||||||
this.CREATE_OPINION_ENDPOINT,
|
this.CREATE_ENDPOINT,
|
||||||
{
|
{
|
||||||
user_email: email,
|
user_email: email,
|
||||||
estabelecimento_id: estabId,
|
estabelecimento_id: estabId,
|
||||||
@@ -140,245 +101,106 @@ class AgentService {
|
|||||||
categoria: categoria.trim(),
|
categoria: categoria.trim(),
|
||||||
instrucoes: instrucoes.trim(),
|
instrucoes: instrucoes.trim(),
|
||||||
},
|
},
|
||||||
{
|
{ timeout: 600_000 }
|
||||||
timeout: 600000, // 5 minutos para geração de parecer (processo demorado)
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Resposta da API (criar parecer):', response.data);
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Trata erros específicos
|
|
||||||
console.error('Erro ao criar parecer:', error);
|
console.error('Erro ao criar parecer:', error);
|
||||||
|
this.toApiError(error);
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Erro ao criar parecer',
|
|
||||||
status: error.status,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Busca todos os pareceres do usuário com paginação e busca
|
|
||||||
*
|
|
||||||
* @param params - Parâmetros de paginação e busca
|
|
||||||
* @returns Promise com array de pareceres
|
|
||||||
*/
|
|
||||||
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
|
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
|
||||||
const {
|
const { page = 1, per_page = 10, search = '', userEmail, estabelecimentoId } = params || {};
|
||||||
page = 1,
|
|
||||||
per_page = 10,
|
|
||||||
search = '',
|
|
||||||
userEmail,
|
|
||||||
estabelecimentoId
|
|
||||||
} = params || {};
|
|
||||||
|
|
||||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
const { email, estabId } = this.resolveUserContext(userEmail, estabelecimentoId);
|
||||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
this.assertUserContext(email, estabId);
|
||||||
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
|
||||||
|
|
||||||
if (!email) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Email do usuário não fornecido',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!estabId) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'ID do estabelecimento não fornecido',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Buscando pareceres:', {
|
|
||||||
userEmail: email,
|
|
||||||
estabelecimentoId: estabId,
|
|
||||||
page,
|
|
||||||
per_page,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Constrói a URL com parâmetros de query
|
const url = `${this.OPINIONS_BASE}/get_parecer/${email}/${estabId}`;
|
||||||
const url = `${this.GET_OPINIONS_ENDPOINT}/${email}/${estabId}`;
|
|
||||||
|
|
||||||
const response = await apiService.get<OpinionRecord[]>(url, {
|
const response = await apiService.get<OpinionRecord[] | GetOpinionsResponse>(url, {
|
||||||
params: {
|
params: { page, per_page, search },
|
||||||
page,
|
|
||||||
per_page,
|
|
||||||
search,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Resposta da API (buscar pareceres):', response.data);
|
const raw = response.data;
|
||||||
|
|
||||||
// A API retorna diretamente o array de pareceres
|
if (Array.isArray(raw)) return raw;
|
||||||
// Garante que sempre retorna um array
|
|
||||||
if (Array.isArray(response.data)) {
|
|
||||||
return response.data;
|
|
||||||
} else if (response.data && typeof response.data === 'object') {
|
|
||||||
// Se a resposta for um objeto, tenta encontrar o array dentro dele
|
|
||||||
console.warn('API retornou objeto em vez de array:', response.data);
|
|
||||||
|
|
||||||
if (Array.isArray((response.data as any).data)) {
|
if (raw && typeof raw === 'object') {
|
||||||
return (response.data as any).data;
|
if (Array.isArray((raw as GetOpinionsResponse).data)) return (raw as GetOpinionsResponse).data;
|
||||||
} else if (Array.isArray((response.data as any).opinions)) {
|
if (Array.isArray((raw as { opinions?: OpinionRecord[] }).opinions)) {
|
||||||
return (response.data as any).opinions;
|
return (raw as unknown as { opinions: OpinionRecord[] }).opinions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se não conseguir extrair array, retorna vazio
|
|
||||||
console.warn('Não foi possível extrair array de pareceres da resposta');
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao buscar pareceres:', error);
|
console.error('Erro ao buscar pareceres:', error);
|
||||||
|
this.toApiError(error);
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Erro ao buscar pareceres',
|
|
||||||
status: error.status,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Faz download de um arquivo de parecer
|
|
||||||
*
|
|
||||||
* @param fileUrl - URL do arquivo a ser baixado
|
|
||||||
* @param fileName - Nome do arquivo para download
|
|
||||||
*/
|
|
||||||
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
|
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
|
||||||
if (!fileUrl) {
|
if (!fileUrl) {
|
||||||
throw {
|
throw { success: false, message: 'URL do arquivo não fornecida' };
|
||||||
success: false,
|
|
||||||
message: 'URL do arquivo não fornecida',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerDownload = (href: string) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = href;
|
||||||
|
link.download = fileName;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Iniciando download:', { fileUrl, fileName });
|
const response = await fetch(fileUrl, { method: 'GET', mode: 'cors', cache: 'no-cache' });
|
||||||
|
|
||||||
// Tenta primeiro fazer o download via fetch (funciona se CORS estiver configurado)
|
if (!response.ok) {
|
||||||
try {
|
throw new Error(`Erro HTTP: ${response.status}`);
|
||||||
const response = await fetch(fileUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
mode: 'cors',
|
|
||||||
cache: 'no-cache',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Erro HTTP: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converte a resposta em blob
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Cria uma URL temporária para o blob
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Cria um elemento <a> temporário para forçar o download
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = fileName;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Remove o elemento e libera a URL temporária
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(blobUrl);
|
|
||||||
|
|
||||||
console.log('Download via fetch concluído:', { fileUrl, fileName });
|
|
||||||
return;
|
|
||||||
} catch (fetchError: any) {
|
|
||||||
console.warn('Erro no download via fetch, tentando método alternativo:', fetchError.message);
|
|
||||||
|
|
||||||
// Se falhar (erro de CORS), usa o método alternativo de abrir em nova aba
|
|
||||||
// Isso permite que o navegador force o download mesmo com restrições de CORS
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = fileUrl;
|
|
||||||
link.download = fileName;
|
|
||||||
link.target = '_blank';
|
|
||||||
link.rel = 'noopener noreferrer';
|
|
||||||
|
|
||||||
// Para S3, podemos tentar adicionar parâmetros que forçam o download
|
|
||||||
const url = new URL(fileUrl);
|
|
||||||
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
|
||||||
link.href = url.toString();
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
console.log('Download via link direto iniciado:', { fileUrl, fileName });
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao fazer download:', error);
|
|
||||||
|
|
||||||
throw {
|
const blob = await response.blob();
|
||||||
success: false,
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
message: error.message || 'Erro ao fazer download do arquivo. Verifique se a URL está acessível.',
|
triggerDownload(blobUrl);
|
||||||
};
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const url = new URL(fileUrl);
|
||||||
|
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||||
|
triggerDownload(url.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exclui um parecer técnico
|
|
||||||
*
|
|
||||||
* @param opinionId - ID do parecer a ser excluído
|
|
||||||
* @param userEmail - Email do usuário (opcional, usa GlobalFunctions se não fornecido)
|
|
||||||
* @returns Promise com a resposta da API
|
|
||||||
*/
|
|
||||||
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
|
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
|
||||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
if (!opinionId) {
|
||||||
|
throw { success: false, message: 'ID do parecer não fornecido' };
|
||||||
|
}
|
||||||
|
|
||||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||||
|
|
||||||
if (!opinionId) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'ID do parecer não fornecido',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw {
|
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||||
success: false,
|
|
||||||
message: 'Email do usuário não fornecido',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Excluindo parecer:', {
|
|
||||||
opinionId,
|
|
||||||
userEmail: email,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Constrói a URL com o user_email e id
|
const url = `${this.OPINIONS_BASE}/delete_parecer/${email}/${opinionId}`;
|
||||||
const url = `/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_parecer/${email}/${opinionId}`;
|
|
||||||
|
|
||||||
const response = await apiService.delete<{ success: boolean }[]>(url);
|
const response = await apiService.delete<{ success: boolean }[]>(url);
|
||||||
|
|
||||||
console.log('Resposta da API (excluir parecer):', response.data);
|
|
||||||
|
|
||||||
// A API retorna um array com { success: true }
|
|
||||||
if (Array.isArray(response.data) && response.data.length > 0) {
|
if (Array.isArray(response.data) && response.data.length > 0) {
|
||||||
return response.data[0];
|
return response.data[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao excluir parecer:', error);
|
console.error('Erro ao excluir parecer:', error);
|
||||||
|
this.toApiError(error);
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Erro ao excluir parecer',
|
|
||||||
status: error.status,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exporta instância única (Singleton)
|
|
||||||
export const agentService = new AgentService();
|
export const agentService = new AgentService();
|
||||||
|
|||||||
+20
-75
@@ -1,63 +1,37 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
/**
|
/** Operações de IA (imagem, áudio, transcrição) podem levar vários minutos. */
|
||||||
* Configuração centralizada da API
|
export const LONG_RUNNING_REQUEST_TIMEOUT_MS = 300_000;
|
||||||
* Todas as chamadas de API devem usar este serviço para garantir
|
|
||||||
* autenticação e configuração consistente
|
|
||||||
*/
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private axiosInstance: AxiosInstance;
|
private readonly axiosInstance: AxiosInstance;
|
||||||
private apiKey: string;
|
private readonly apiKey: string;
|
||||||
private baseURL: string;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Busca configurações das variáveis de ambiente
|
|
||||||
this.apiKey = import.meta.env.VITE_API_KEY || '';
|
this.apiKey = import.meta.env.VITE_API_KEY || '';
|
||||||
this.baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
const baseURL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
// Validação das variáveis de ambiente
|
|
||||||
if (!this.apiKey) {
|
|
||||||
console.error('VITE_API_KEY não configurada no arquivo .env');
|
|
||||||
}
|
|
||||||
if (!this.baseURL) {
|
|
||||||
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cria instância do Axios com configurações padrão
|
|
||||||
this.axiosInstance = axios.create({
|
this.axiosInstance = axios.create({
|
||||||
baseURL: this.baseURL,
|
baseURL,
|
||||||
timeout: 60000, // 60 segundos para upload de arquivos
|
timeout: 60_000,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Interceptor para adicionar API Key em todas as requisições
|
this.axiosInstance.interceptors.request.use((config) => {
|
||||||
this.axiosInstance.interceptors.request.use(
|
if (this.apiKey) {
|
||||||
(config) => {
|
config.headers['apikey'] = this.apiKey;
|
||||||
// Adiciona a API Key no header de todas as requisições
|
|
||||||
if (this.apiKey) {
|
|
||||||
config.headers['apikey'] = this.apiKey;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
}
|
||||||
);
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
// Interceptor de resposta para tratamento centralizado de erros
|
|
||||||
this.axiosInstance.interceptors.response.use(
|
this.axiosInstance.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
// Log de erro para debug
|
|
||||||
console.error('API Error:', {
|
console.error('API Error:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
data: error.response?.data,
|
data: error.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Retorna erro formatado
|
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
|
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
@@ -67,61 +41,32 @@ class ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
* Requisição GET
|
|
||||||
*/
|
|
||||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.get<T>(url, config);
|
return this.axiosInstance.get<T>(url, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
post<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
* Requisição POST
|
|
||||||
*/
|
|
||||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.post<T>(url, data, config);
|
return this.axiosInstance.post<T>(url, data, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
postFormData<T = unknown>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
* Requisição POST com FormData (para upload de arquivos)
|
|
||||||
*/
|
|
||||||
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.post<T>(url, formData, {
|
return this.axiosInstance.post<T>(url, formData, {
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: { ...config?.headers, 'Content-Type': 'multipart/form-data' },
|
||||||
...config?.headers,
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
put<T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
* Requisição PUT
|
|
||||||
*/
|
|
||||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.put<T>(url, data, config);
|
return this.axiosInstance.put<T>(url, data, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||||
* Requisição DELETE
|
|
||||||
*/
|
|
||||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
|
||||||
return this.axiosInstance.delete<T>(url, config);
|
return this.axiosInstance.delete<T>(url, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retorna a URL base configurada
|
|
||||||
*/
|
|
||||||
getBaseURL(): string {
|
|
||||||
return this.baseURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retorna a instância do Axios (uso avançado)
|
|
||||||
*/
|
|
||||||
getInstance(): AxiosInstance {
|
getInstance(): AxiosInstance {
|
||||||
return this.axiosInstance;
|
return this.axiosInstance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exporta instância única (Singleton)
|
|
||||||
export const apiService = new ApiService();
|
export const apiService = new ApiService();
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { apiService } from "./api";
|
||||||
|
|
||||||
|
export interface AreaItem {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriarAreaRequest {
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriarAreaSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CriarAreaErrorResponse {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CriarAreaResponse = CriarAreaSuccessResponse | CriarAreaErrorResponse;
|
||||||
|
|
||||||
|
export interface EditarAreaBody {
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditarAreaSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
descricao: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditarAreaErrorResponse {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletarAreaSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletarAreaErrorResponse {
|
||||||
|
success: false;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListarAreasParams {
|
||||||
|
nome?: string;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListarAreasResponseBody {
|
||||||
|
success: true;
|
||||||
|
total_registros: number;
|
||||||
|
total_paginas: number;
|
||||||
|
per_page: number;
|
||||||
|
pagina_atual: number;
|
||||||
|
data: AreaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AreasService {
|
||||||
|
async criar(nome: string, descricao: string): Promise<CriarAreaSuccessResponse> {
|
||||||
|
const body: CriarAreaRequest = {
|
||||||
|
nome: nome.trim(),
|
||||||
|
descricao: (descricao ?? "").trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiService.post<CriarAreaResponse>(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/criar-area",
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success === false) {
|
||||||
|
throw new Error(response.data.message ?? "Erro ao criar área");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as CriarAreaSuccessResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listar(params: ListarAreasParams): Promise<ListarAreasResponseBody> {
|
||||||
|
const { page, per_page, nome } = params;
|
||||||
|
|
||||||
|
const response = await apiService.get<ListarAreasResponseBody[]>(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/listar-areas",
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...(nome != null && nome.trim() !== "" ? { nome: nome.trim() } : {}),
|
||||||
|
page,
|
||||||
|
per_page,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||||
|
|
||||||
|
if (!first || first.success !== true) {
|
||||||
|
throw new Error("Resposta inválida ao listar áreas");
|
||||||
|
}
|
||||||
|
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarTotal(): Promise<AreaItem[]> {
|
||||||
|
interface ListarAreasTotalResponseItem {
|
||||||
|
success: true;
|
||||||
|
data: AreaItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.get<ListarAreasTotalResponseItem[]>(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/listar-areas-total"
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||||
|
|
||||||
|
if (!first || first.success !== true || !Array.isArray(first.data)) {
|
||||||
|
throw new Error("Resposta inválida ao listar áreas");
|
||||||
|
}
|
||||||
|
|
||||||
|
return first.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async editar(id: string, nome: string, descricao: string): Promise<EditarAreaSuccessResponse> {
|
||||||
|
if (!id?.trim()) {
|
||||||
|
throw new Error("ID da área é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
const idEnc = encodeURIComponent(id.trim());
|
||||||
|
const body: EditarAreaBody = {
|
||||||
|
nome: nome.trim(),
|
||||||
|
descricao: (descricao ?? "").trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Mesmo prefixo de webhook que o fluxo n8n (UUID) usado nos endpoints de "parecer/areas". */
|
||||||
|
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/parecer/area/${idEnc}`;
|
||||||
|
|
||||||
|
const response = await apiService.put<
|
||||||
|
EditarAreaSuccessResponse | EditarAreaErrorResponse | (EditarAreaSuccessResponse | EditarAreaErrorResponse)[]
|
||||||
|
>(
|
||||||
|
url,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = response.data;
|
||||||
|
const data = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
|
||||||
|
if (!data || typeof data !== "object" || !("success" in data)) {
|
||||||
|
throw new Error("Resposta inválida ao editar área");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success === false) {
|
||||||
|
throw new Error((data as EditarAreaErrorResponse).message ?? "Erro ao editar área");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as EditarAreaSuccessResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletar(id: string): Promise<DeletarAreaSuccessResponse> {
|
||||||
|
if (!id?.trim()) {
|
||||||
|
throw new Error("ID da área é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
const idEnc = encodeURIComponent(id.trim());
|
||||||
|
/** Mesmo prefixo de webhook que `parecerApi.excluir` (fluxo n8n com UUID). */
|
||||||
|
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/4e6c2374-9c22-4c81-b558-45f0cfefa5c3/codex/parecer/areas/deletar/${idEnc}`;
|
||||||
|
|
||||||
|
const response = await apiService.delete<
|
||||||
|
DeletarAreaSuccessResponse | DeletarAreaErrorResponse | (DeletarAreaSuccessResponse | DeletarAreaErrorResponse)[]
|
||||||
|
>(url);
|
||||||
|
|
||||||
|
const raw = response.data;
|
||||||
|
const data = Array.isArray(raw) ? raw[0] : raw;
|
||||||
|
|
||||||
|
if (!data || typeof data !== "object" || !("success" in data)) {
|
||||||
|
throw new Error("Resposta inválida ao excluir área");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success === false) {
|
||||||
|
throw new Error((data as DeletarAreaErrorResponse).message ?? "Erro ao excluir área");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as DeletarAreaSuccessResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const areasService = new AreasService();
|
||||||
+70
-318
@@ -1,58 +1,36 @@
|
|||||||
/**
|
import axios from 'axios';
|
||||||
* Serviço para integração com API do Asana
|
import { GlobalFunctions } from '@/GlobalFunctions';
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para workspace do Asana (resposta da API)
|
|
||||||
*/
|
|
||||||
export interface AsanaWorkspaceResponse {
|
export interface AsanaWorkspaceResponse {
|
||||||
gid: string;
|
gid: string;
|
||||||
resource_type: string;
|
resource_type: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para resposta da API de workspaces
|
|
||||||
*/
|
|
||||||
export interface AsanaWorkspacesResponse {
|
export interface AsanaWorkspacesResponse {
|
||||||
data: AsanaWorkspaceResponse[];
|
data: AsanaWorkspaceResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para workspace formatado (usado no componente)
|
|
||||||
*/
|
|
||||||
export interface AsanaWorkspace {
|
export interface AsanaWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para usuário do Asana (resposta da API)
|
|
||||||
*/
|
|
||||||
export interface AsanaUserResponse {
|
export interface AsanaUserResponse {
|
||||||
gid: string;
|
gid: string;
|
||||||
name: string;
|
name: string;
|
||||||
resource_type: string;
|
resource_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para resposta da API de usuários
|
|
||||||
*/
|
|
||||||
export interface AsanaUsersResponse {
|
export interface AsanaUsersResponse {
|
||||||
data: AsanaUserResponse[];
|
data: AsanaUserResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para usuário formatado (usado no componente)
|
|
||||||
*/
|
|
||||||
export interface AsanaUser {
|
export interface AsanaUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para resposta da API de integração do Asana
|
|
||||||
*/
|
|
||||||
export interface AsanaIntegrationResponse {
|
export interface AsanaIntegrationResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
integracao_id?: string;
|
integracao_id?: string;
|
||||||
@@ -64,9 +42,6 @@ export interface AsanaIntegrationResponse {
|
|||||||
usuario_asana_nome?: string;
|
usuario_asana_nome?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para request de criação/atualização de integração
|
|
||||||
*/
|
|
||||||
export interface AsanaIntegrationRequest {
|
export interface AsanaIntegrationRequest {
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
@@ -76,360 +51,137 @@ export interface AsanaIntegrationRequest {
|
|||||||
usuario_asana_nome: string;
|
usuario_asana_nome: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type AxiosLikeError = { response?: { data?: { message?: string }; status?: number } };
|
||||||
* Serviço para integração com Asana
|
|
||||||
*/
|
class AsanaService {
|
||||||
class AsanaService {
|
private readonly ASANA_BASE_URL = 'https://app.asana.com/api/1.0';
|
||||||
private readonly BASE_URL = 'https://app.asana.com/api/1.0';
|
|
||||||
private readonly INTEGRATION_BASE_URL = 'https://prod-hgtx-intelligence-n8n.hgtx.com.br';
|
private asanaHeaders(token: string): Record<string, string> {
|
||||||
private readonly INTEGRATION_WEBHOOK_ID = 'c898beff-84cb-44df-a69c-6eff27ccd7aa';
|
return {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${token.trim()}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async n8nHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await GlobalFunctions.getToken();
|
||||||
|
const apiKey = import.meta.env.VITE_API_KEY || '';
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
};
|
||||||
|
if (apiKey) headers['apikey'] = apiKey;
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAsanaError(error: unknown, fallback: string): never {
|
||||||
|
const e = error as AxiosLikeError;
|
||||||
|
if (e?.response?.status === 401) {
|
||||||
|
throw { success: false, message: 'Token inválido ou expirado. Verifique sua chave de API.' };
|
||||||
|
}
|
||||||
|
if (e?.response?.data) {
|
||||||
|
throw { success: false, message: e.response.data.message || fallback };
|
||||||
|
}
|
||||||
|
throw { success: false, message: error instanceof Error ? error.message : fallback };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lista os workspaces do Asana usando o token fornecido
|
|
||||||
*
|
|
||||||
* @param token - Token de acesso do Asana
|
|
||||||
* @returns Promise com a lista de workspaces formatados
|
|
||||||
*/
|
|
||||||
async getWorkspaces(token: string): Promise<AsanaWorkspace[]> {
|
async getWorkspaces(token: string): Promise<AsanaWorkspace[]> {
|
||||||
if (!token || token.trim().length === 0) {
|
if (!token?.trim()) {
|
||||||
throw {
|
throw { success: false, message: 'Token do Asana é obrigatório' };
|
||||||
success: false,
|
|
||||||
message: 'Token do Asana é obrigatório',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import('axios')).default;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'authorization': `Bearer ${token.trim()}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.get<AsanaWorkspacesResponse>(
|
const response = await axios.get<AsanaWorkspacesResponse>(
|
||||||
`${this.BASE_URL}/workspaces`,
|
`${this.ASANA_BASE_URL}/workspaces`,
|
||||||
{ headers }
|
{ headers: this.asanaHeaders(token) }
|
||||||
);
|
);
|
||||||
|
return response.data.data.map((w) => ({ id: w.gid, name: w.name }));
|
||||||
// Converte a resposta da API para o formato esperado pelo componente
|
|
||||||
// A API retorna gid, mas o componente espera id
|
|
||||||
return response.data.data.map((workspace) => ({
|
|
||||||
id: workspace.gid,
|
|
||||||
name: workspace.name,
|
|
||||||
}));
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao buscar workspaces do Asana:', error);
|
console.error('Erro ao buscar workspaces do Asana:', error);
|
||||||
|
this.handleAsanaError(error, 'Erro ao buscar workspaces do Asana');
|
||||||
// Retorna o erro da API se existir
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
|
||||||
|
|
||||||
if (axiosError.response?.status === 401) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Token inválido ou expirado. Verifique sua chave de API.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (axiosError.response?.data) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: axiosError.response.data.message || 'Erro ao buscar workspaces do Asana',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Erro ao buscar workspaces do Asana',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lista os usuários de um workspace do Asana
|
|
||||||
*
|
|
||||||
* @param token - Token de acesso do Asana
|
|
||||||
* @param workspaceId - ID do workspace (gid)
|
|
||||||
* @returns Promise com a lista de usuários formatados
|
|
||||||
*/
|
|
||||||
async getUsers(token: string, workspaceId: string): Promise<AsanaUser[]> {
|
async getUsers(token: string, workspaceId: string): Promise<AsanaUser[]> {
|
||||||
if (!token || token.trim().length === 0) {
|
if (!token?.trim()) {
|
||||||
throw {
|
throw { success: false, message: 'Token do Asana é obrigatório' };
|
||||||
success: false,
|
|
||||||
message: 'Token do Asana é obrigatório',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (!workspaceId?.trim()) {
|
||||||
if (!workspaceId || workspaceId.trim().length === 0) {
|
throw { success: false, message: 'ID do workspace é obrigatório' };
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'ID do workspace é obrigatório',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import('axios')).default;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'accept': 'application/json',
|
|
||||||
'authorization': `Bearer ${token.trim()}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await axios.get<AsanaUsersResponse>(
|
const response = await axios.get<AsanaUsersResponse>(
|
||||||
`${this.BASE_URL}/users?workspace=${workspaceId}`,
|
`${this.ASANA_BASE_URL}/users?workspace=${workspaceId}`,
|
||||||
{ headers }
|
{ headers: this.asanaHeaders(token) }
|
||||||
);
|
);
|
||||||
|
return response.data.data.map((u) => ({ id: u.gid, name: u.name }));
|
||||||
// Converte a resposta da API para o formato esperado pelo componente
|
|
||||||
// A API retorna gid, mas o componente espera id
|
|
||||||
return response.data.data.map((user) => ({
|
|
||||||
id: user.gid,
|
|
||||||
name: user.name,
|
|
||||||
}));
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao buscar usuários do Asana:', error);
|
console.error('Erro ao buscar usuários do Asana:', error);
|
||||||
|
this.handleAsanaError(error, 'Erro ao buscar usuários do Asana');
|
||||||
// Retorna o erro da API se existir
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
|
||||||
|
|
||||||
if (axiosError.response?.status === 401) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Token inválido ou expirado. Verifique sua chave de API.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (axiosError.response?.data) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: axiosError.response.data.message || 'Erro ao buscar usuários do Asana',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Erro ao buscar usuários do Asana',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtém os detalhes da integração do Asana do usuário
|
|
||||||
*
|
|
||||||
* @param userId - ID do usuário
|
|
||||||
* @returns Promise com os detalhes da integração ou null se não houver
|
|
||||||
*/
|
|
||||||
async getIntegration(userId: string): Promise<AsanaIntegrationResponse | null> {
|
async getIntegration(userId: string): Promise<AsanaIntegrationResponse | null> {
|
||||||
if (!userId || userId.trim().length === 0) {
|
if (!userId?.trim()) {
|
||||||
throw {
|
throw { success: false, message: 'ID do usuário é obrigatório' };
|
||||||
success: false,
|
|
||||||
message: 'ID do usuário é obrigatório',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import('axios')).default;
|
const headers = await this.n8nHeaders();
|
||||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
|
||||||
|
|
||||||
// Obtém o token JWT para autenticação
|
|
||||||
const token = await GlobalFunctions.getToken();
|
|
||||||
const apiKey = import.meta.env.VITE_API_KEY || '';
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'accept': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adiciona API key (obrigatória)
|
|
||||||
if (apiKey) {
|
|
||||||
headers['apikey'] = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adiciona token JWT se disponível
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Em desenvolvimento, usa proxy do Vite para evitar CORS
|
|
||||||
const baseUrl = import.meta.env.DEV
|
|
||||||
? '/api/intelligence'
|
|
||||||
: this.INTEGRATION_BASE_URL;
|
|
||||||
|
|
||||||
const response = await axios.get<AsanaIntegrationResponse>(
|
const response = await axios.get<AsanaIntegrationResponse>(
|
||||||
`${baseUrl}/webhook/${this.INTEGRATION_WEBHOOK_ID}/codex/agente-pessoal/integracoes/asana/${userId}`,
|
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${userId}`,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
return response.data.success ? response.data : null;
|
||||||
// Se success é false, retorna null (não tem integração)
|
|
||||||
if (!response.data.success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao buscar integração do Asana:', error);
|
const e = error as AxiosLikeError;
|
||||||
|
if (e?.response?.status === 404) return null;
|
||||||
// Se o erro for 404 ou similar, significa que não tem integração cadastrada
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as { response?: { status?: number } };
|
|
||||||
if (axiosError.response?.status === 404) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Para outros erros, retorna null silenciosamente (não quebra a aplicação)
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cria uma nova integração do Asana
|
|
||||||
*
|
|
||||||
* @param request - Dados da integração a ser criada
|
|
||||||
* @returns Promise com a resposta da criação
|
|
||||||
*/
|
|
||||||
async createIntegration(request: AsanaIntegrationRequest): Promise<AsanaIntegrationResponse> {
|
async createIntegration(request: AsanaIntegrationRequest): Promise<AsanaIntegrationResponse> {
|
||||||
if (!request.user_id || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
|
if (!request.user_id || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
|
||||||
throw {
|
throw { success: false, message: 'Dados incompletos para criar integração' };
|
||||||
success: false,
|
|
||||||
message: 'Dados incompletos para criar integração',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import('axios')).default;
|
const headers = await this.n8nHeaders();
|
||||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
|
||||||
|
|
||||||
// Obtém o token JWT para autenticação
|
|
||||||
const token = await GlobalFunctions.getToken();
|
|
||||||
const apiKey = import.meta.env.VITE_API_KEY || '';
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'accept': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adiciona API key (obrigatória)
|
|
||||||
if (apiKey) {
|
|
||||||
headers['apikey'] = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adiciona token JWT se disponível
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Em desenvolvimento, usa proxy do Vite para evitar CORS
|
|
||||||
const baseUrl = import.meta.env.DEV
|
|
||||||
? '/api/intelligence'
|
|
||||||
: this.INTEGRATION_BASE_URL;
|
|
||||||
|
|
||||||
const response = await axios.post<AsanaIntegrationResponse>(
|
const response = await axios.post<AsanaIntegrationResponse>(
|
||||||
`${baseUrl}/webhook/codex/agente-pessoal/integracoes/asana`,
|
'https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/integracoes/asana',
|
||||||
request,
|
request,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao criar integração do Asana:', error);
|
console.error('Erro ao criar integração do Asana:', error);
|
||||||
|
this.handleAsanaError(error, 'Erro ao criar integração do Asana');
|
||||||
// Retorna o erro da API se existir
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
|
||||||
|
|
||||||
if (axiosError.response?.data) {
|
|
||||||
const errorData = axiosError.response.data;
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: errorData.message || 'Erro ao criar integração do Asana',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Erro ao criar integração do Asana',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async updateIntegration(
|
||||||
* Atualiza uma integração existente do Asana
|
integracaoId: string,
|
||||||
*
|
request: Omit<AsanaIntegrationRequest, 'user_id'>
|
||||||
* @param integracaoId - ID da integração a ser atualizada
|
): Promise<AsanaIntegrationResponse> {
|
||||||
* @param request - Dados da integração a ser atualizada
|
|
||||||
* @returns Promise com a resposta da atualização
|
|
||||||
*/
|
|
||||||
async updateIntegration(integracaoId: string, request: Omit<AsanaIntegrationRequest, 'user_id'>): Promise<AsanaIntegrationResponse> {
|
|
||||||
if (!integracaoId || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
|
if (!integracaoId || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
|
||||||
throw {
|
throw { success: false, message: 'Dados incompletos para atualizar integração' };
|
||||||
success: false,
|
|
||||||
message: 'Dados incompletos para atualizar integração',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import('axios')).default;
|
const headers = await this.n8nHeaders();
|
||||||
const { GlobalFunctions } = await import('@/GlobalFunctions');
|
const response = await axios.post<AsanaIntegrationResponse>(
|
||||||
|
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${integracaoId}`,
|
||||||
// Obtém o token JWT para autenticação
|
|
||||||
const token = await GlobalFunctions.getToken();
|
|
||||||
const apiKey = import.meta.env.VITE_API_KEY || '';
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'accept': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Adiciona API key (obrigatória)
|
|
||||||
if (apiKey) {
|
|
||||||
headers['apikey'] = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adiciona token JWT se disponível
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Em desenvolvimento, usa proxy do Vite para evitar CORS
|
|
||||||
const baseUrl = import.meta.env.DEV
|
|
||||||
? '/api/intelligence'
|
|
||||||
: this.INTEGRATION_BASE_URL;
|
|
||||||
|
|
||||||
const response = await axios.put<AsanaIntegrationResponse>(
|
|
||||||
`${baseUrl}/webhook/${this.INTEGRATION_WEBHOOK_ID}/codex/agente-pessoal/integracoes/asana/${integracaoId}`,
|
|
||||||
request,
|
request,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Erro ao atualizar integração do Asana:', error);
|
console.error('Erro ao atualizar integração do Asana:', error);
|
||||||
|
this.handleAsanaError(error, 'Erro ao atualizar integração do Asana');
|
||||||
// Retorna o erro da API se existir
|
|
||||||
if (error && typeof error === 'object' && 'response' in error) {
|
|
||||||
const axiosError = error as { response?: { data?: any; status?: number } };
|
|
||||||
|
|
||||||
if (axiosError.response?.data) {
|
|
||||||
const errorData = axiosError.response.data;
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: errorData.message || 'Erro ao atualizar integração do Asana',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Erro ao atualizar integração do Asana',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exporta instância única (Singleton)
|
|
||||||
export const asanaService = new AsanaService();
|
export const asanaService = new AsanaService();
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { apiService } from "./api";
|
||||||
|
|
||||||
|
const ASSISTANT_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
|
export interface AssistantOutputResponse {
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssistentePromptsService {
|
||||||
|
async gerar(instrucao: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
if (!instrucao?.trim()) {
|
||||||
|
throw new Error("Instrução é obrigatória");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.post<AssistantOutputResponse>(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/assistente-criar-prompt",
|
||||||
|
{ instrucao: instrucao.trim() },
|
||||||
|
{ timeout: ASSISTANT_TIMEOUT_MS, signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.output != null) {
|
||||||
|
return String(response.data.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Resposta da API sem texto gerado (output)");
|
||||||
|
}
|
||||||
|
|
||||||
|
async refinar(promptAtual: string, instrucao: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
if (!promptAtual?.trim()) {
|
||||||
|
throw new Error("Prompt atual é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instrucao?.trim()) {
|
||||||
|
throw new Error("Instrução é obrigatória");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiService.post<AssistantOutputResponse>(
|
||||||
|
"https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/parecer/assistente-melhorar-prompt",
|
||||||
|
{
|
||||||
|
instrucao: instrucao.trim(),
|
||||||
|
prompt_atual: promptAtual.trim(),
|
||||||
|
},
|
||||||
|
{ timeout: ASSISTANT_TIMEOUT_MS, signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.output != null) {
|
||||||
|
return String(response.data.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Resposta da API sem texto gerado (output)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assistentePromptsService = new AssistentePromptsService();
|
||||||
+171
-262
@@ -1,24 +1,15 @@
|
|||||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
import { apiService } from './api';
|
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipos de vozes disponíveis para geração de áudio
|
|
||||||
*/
|
|
||||||
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para a resposta da API de geração de áudio
|
|
||||||
*/
|
|
||||||
export interface AudioGenerationResponse {
|
export interface AudioGenerationResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
audio_url: string; // URL do áudio gerado
|
audio_url: string;
|
||||||
audio_generation_id: string;
|
audio_generation_id: string;
|
||||||
message: string; // Texto que foi convertido em áudio
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para um áudio armazenado no banco de dados
|
|
||||||
*/
|
|
||||||
export interface AudioRecord {
|
export interface AudioRecord {
|
||||||
id: string;
|
id: string;
|
||||||
user_email: string;
|
user_email: string;
|
||||||
@@ -33,9 +24,6 @@ export interface AudioRecord {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface para os dados necessários para geração de áudio
|
|
||||||
*/
|
|
||||||
export interface AudioGenerationRequest {
|
export interface AudioGenerationRequest {
|
||||||
message: string;
|
message: string;
|
||||||
voice: VoiceType;
|
voice: VoiceType;
|
||||||
@@ -43,143 +31,80 @@ export interface AudioGenerationRequest {
|
|||||||
estabelecimentoId?: number;
|
estabelecimentoId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Informações sobre cada tipo de voz disponível
|
|
||||||
*/
|
|
||||||
export const VOICE_OPTIONS = {
|
export const VOICE_OPTIONS = {
|
||||||
alloy: {
|
alloy: {
|
||||||
label: "Alloy",
|
label: 'Alloy',
|
||||||
gender: "Masculina",
|
gender: 'Masculina',
|
||||||
style: "Neutra, equilibrada, tom corporativo",
|
style: 'Neutra, equilibrada, tom corporativo',
|
||||||
description: "Boa para tutoriais e comunicações institucionais."
|
description: 'Boa para tutoriais e comunicações institucionais.',
|
||||||
},
|
},
|
||||||
echo: {
|
echo: {
|
||||||
label: "Echo",
|
label: 'Echo',
|
||||||
gender: "Masculina",
|
gender: 'Masculina',
|
||||||
style: "Forte e profissional, mais grave",
|
style: 'Forte e profissional, mais grave',
|
||||||
description: "Ideal para voz de autoridade ou locução firme."
|
description: 'Ideal para voz de autoridade ou locução firme.',
|
||||||
},
|
},
|
||||||
fable: {
|
fable: {
|
||||||
label: "Fable",
|
label: 'Fable',
|
||||||
gender: "Feminina",
|
gender: 'Feminina',
|
||||||
style: "Narrativa, calorosa e envolvente",
|
style: 'Narrativa, calorosa e envolvente',
|
||||||
description: "Ótima para storytelling e áudios empáticos."
|
description: 'Ótima para storytelling e áudios empáticos.',
|
||||||
},
|
},
|
||||||
onyx: {
|
onyx: {
|
||||||
label: "Onyx",
|
label: 'Onyx',
|
||||||
gender: "Masculina",
|
gender: 'Masculina',
|
||||||
style: "Grave, autoritária, impactante",
|
style: 'Grave, autoritária, impactante',
|
||||||
description: "Excelente para trailers, mensagens sérias ou institucionais."
|
description: 'Excelente para trailers, mensagens sérias ou institucionais.',
|
||||||
},
|
},
|
||||||
nova: {
|
nova: {
|
||||||
label: "Nova",
|
label: 'Nova',
|
||||||
gender: "Feminina",
|
gender: 'Feminina',
|
||||||
style: "Brilhante, animada, energética",
|
style: 'Brilhante, animada, energética',
|
||||||
description: "Boa para vídeos curtos, marketing ou conteúdos leves."
|
description: 'Boa para vídeos curtos, marketing ou conteúdos leves.',
|
||||||
},
|
},
|
||||||
shimmer: {
|
shimmer: {
|
||||||
label: "Shimmer",
|
label: 'Shimmer',
|
||||||
gender: "Feminina",
|
gender: 'Feminina',
|
||||||
style: "Suave, otimista, clara",
|
style: 'Suave, otimista, clara',
|
||||||
description: "Boa para mensagens acolhedoras, explicações e IA conversacional."
|
description: 'Boa para mensagens acolhedoras, explicações e IA conversacional.',
|
||||||
}
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
type ApiErrorShape = { message?: string; status?: number };
|
||||||
* Serviço de geração de áudio (Text-to-Speech)
|
|
||||||
*/
|
|
||||||
class AudioGenerationService {
|
class AudioGenerationService {
|
||||||
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
|
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
|
||||||
private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios';
|
private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios';
|
||||||
private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
|
private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
|
||||||
|
|
||||||
/**
|
private resolveEmail(userEmail?: string): string {
|
||||||
* Gera um arquivo de áudio a partir de texto
|
return userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||||
*
|
}
|
||||||
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
|
|
||||||
* @returns Promise com a resposta da API
|
private toApiError(error: unknown, fallback: string): never {
|
||||||
*/
|
const e = error as ApiErrorShape;
|
||||||
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
throw { success: false, message: e?.message || fallback, status: e?.status };
|
||||||
const { message, voice, userEmail, estabelecimentoId } = request;
|
}
|
||||||
|
|
||||||
// Usa valores do .env se não forem fornecidos
|
private extractArray<T>(data: unknown, keys: string[]): T[] {
|
||||||
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
|
if (Array.isArray(data)) return data as T[];
|
||||||
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
|
if (data && typeof data === 'object') {
|
||||||
|
for (const key of keys) {
|
||||||
// Valida o texto
|
const candidate = (data as Record<string, unknown>)[key];
|
||||||
if (!message || message.trim().length === 0) {
|
if (Array.isArray(candidate)) return candidate as T[];
|
||||||
throw {
|
}
|
||||||
success: false,
|
}
|
||||||
message: 'O texto não pode estar vazio',
|
return [];
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valida a voz
|
|
||||||
if (!this.isValidVoice(voice)) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log para debug (remover em produção se necessário)
|
|
||||||
console.log('Gerando áudio:', {
|
|
||||||
messageLength: message.length,
|
|
||||||
voice,
|
|
||||||
userEmail: email,
|
|
||||||
estabelecimentoId: estabId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Faz a requisição usando o serviço de API
|
|
||||||
const response = await apiService.post<AudioGenerationResponse>(
|
|
||||||
this.AUDIO_GENERATION_ENDPOINT,
|
|
||||||
{
|
|
||||||
estabelecimento_id: estabId,
|
|
||||||
user_email: email,
|
|
||||||
message: message,
|
|
||||||
voice: voice,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
// Trata erros específicos
|
|
||||||
console.error('Erro na geração de áudio:', error);
|
|
||||||
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: error.message || 'Erro ao gerar áudio',
|
|
||||||
status: error.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Valida se a voz selecionada é suportada
|
|
||||||
*
|
|
||||||
* @param voice - Voz a ser validada
|
|
||||||
* @returns true se a voz é válida
|
|
||||||
*/
|
|
||||||
isValidVoice(voice: string): voice is VoiceType {
|
isValidVoice(voice: string): voice is VoiceType {
|
||||||
return Object.keys(VOICE_OPTIONS).includes(voice);
|
return Object.keys(VOICE_OPTIONS).includes(voice);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtém informações sobre uma voz específica
|
|
||||||
*
|
|
||||||
* @param voice - Tipo de voz
|
|
||||||
* @returns Informações da voz
|
|
||||||
*/
|
|
||||||
getVoiceInfo(voice: VoiceType) {
|
getVoiceInfo(voice: VoiceType) {
|
||||||
return VOICE_OPTIONS[voice];
|
return VOICE_OPTIONS[voice];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lista todas as vozes disponíveis
|
|
||||||
*
|
|
||||||
* @returns Array com todas as opções de voz
|
|
||||||
*/
|
|
||||||
getAllVoices() {
|
getAllVoices() {
|
||||||
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
|
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
|
||||||
value: key as VoiceType,
|
value: key as VoiceType,
|
||||||
@@ -187,174 +112,158 @@ class AudioGenerationService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
validateText(text: string, maxLength = 4096): { valid: boolean; error?: string } {
|
||||||
* Valida o texto para geração de áudio
|
if (!text?.trim()) return { valid: false, error: 'O texto não pode estar vazio' };
|
||||||
*
|
if (text.length > maxLength) return { valid: false, error: `O texto é muito longo. Máximo: ${maxLength} caracteres` };
|
||||||
* @param text - Texto a ser validado
|
|
||||||
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
|
|
||||||
* @returns Objeto com resultado da validação
|
|
||||||
*/
|
|
||||||
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
|
|
||||||
if (!text || text.trim().length === 0) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'O texto não pode estar vazio',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.length > maxLength) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
|
||||||
* Lista áudios do banco de dados com paginação
|
const { message, voice } = request;
|
||||||
*
|
|
||||||
* @param userEmail - Email do usuário
|
|
||||||
* @param page - Número da página (padrão: 1)
|
|
||||||
* @param perPage - Quantidade de itens por página (padrão: 10)
|
|
||||||
* @returns Promise com o array de áudios
|
|
||||||
*/
|
|
||||||
async getAudios(
|
|
||||||
userEmail?: string,
|
|
||||||
page: number = 1,
|
|
||||||
perPage: number = 10
|
|
||||||
): Promise<AudioRecord[]> {
|
|
||||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
|
||||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
|
||||||
|
|
||||||
if (!email) {
|
if (!message?.trim()) {
|
||||||
throw {
|
throw { success: false, message: 'O texto não pode estar vazio' };
|
||||||
success: false,
|
|
||||||
message: 'Email do usuário não fornecido',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Buscando áudios:', {
|
if (!this.isValidVoice(voice)) {
|
||||||
userEmail: email,
|
throw { success: false, message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}` };
|
||||||
page,
|
}
|
||||||
perPage,
|
|
||||||
});
|
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||||
|
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Faz a requisição GET com parâmetros na URL e query
|
const response = await apiService.post<AudioGenerationResponse>(
|
||||||
const response = await apiService.get<AudioRecord[]>(
|
this.AUDIO_GENERATION_ENDPOINT,
|
||||||
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
|
{ estabelecimento_id: estabId, user_email: email, message, voice },
|
||||||
{
|
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
|
||||||
params: {
|
|
||||||
page: page.toString(),
|
|
||||||
per_page: perPage.toString(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Erro na geração de áudio:', error);
|
||||||
|
this.toApiError(error, 'Erro ao gerar áudio');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Resposta completa da API:', response);
|
async getAudios(userEmail?: string, page = 1, perPage = 10): Promise<AudioRecord[]> {
|
||||||
console.log('response.data:', response.data);
|
const email = this.resolveEmail(userEmail);
|
||||||
console.log('É array?:', Array.isArray(response.data));
|
|
||||||
|
|
||||||
// A API retorna diretamente o array de áudios
|
if (!email) {
|
||||||
// Garante que sempre retorna um array
|
throw { success: false, message: 'Email do usuário não fornecido' };
|
||||||
if (Array.isArray(response.data)) {
|
}
|
||||||
return response.data;
|
|
||||||
} else if (response.data && typeof response.data === 'object') {
|
|
||||||
// Se a resposta for um objeto com uma propriedade 'audios' ou similar
|
|
||||||
console.warn('API retornou objeto em vez de array:', response.data);
|
|
||||||
|
|
||||||
// Tenta encontrar o array dentro do objeto
|
try {
|
||||||
if (Array.isArray((response.data as any).audios)) {
|
const response = await apiService.get<unknown>(
|
||||||
return (response.data as any).audios;
|
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
|
||||||
} else if (Array.isArray((response.data as any).data)) {
|
{ params: { page: page.toString(), per_page: perPage.toString() } }
|
||||||
return (response.data as any).data;
|
);
|
||||||
}
|
return this.extractArray<AudioRecord>(response.data, ['audios', 'data']);
|
||||||
}
|
} catch (error: unknown) {
|
||||||
|
|
||||||
// Se não conseguir extrair array, retorna vazio
|
|
||||||
console.warn('Não foi possível extrair array de áudios da resposta');
|
|
||||||
return [];
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao buscar áudios:', error);
|
console.error('Erro ao buscar áudios:', error);
|
||||||
|
this.toApiError(error, 'Erro ao buscar áudios');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw {
|
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
||||||
success: false,
|
const email = this.resolveEmail(userEmail);
|
||||||
message: error.message || 'Erro ao buscar áudios',
|
|
||||||
status: error.status,
|
if (!email) throw { success: false, message: 'Email do usuário não fornecido' };
|
||||||
};
|
if (!audioId) throw { success: false, message: 'ID do áudio não fornecido' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
||||||
|
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
|
||||||
|
);
|
||||||
|
const result = Array.isArray(response.data) ? response.data[0] : response.data;
|
||||||
|
return { success: result.success ?? true, message: result.message || 'Áudio deletado com sucesso' };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Erro ao deletar áudio:', error);
|
||||||
|
this.toApiError(error, 'Erro ao deletar áudio');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deleta um áudio do banco de dados
|
* Baixa o arquivo no navegador (sem navegar para a URL).
|
||||||
*
|
* Usa blob local + <a download> — necessário porque links cross-origin ignoram `download` e abrem a URL.
|
||||||
* @param audioId - ID do áudio a ser deletado
|
|
||||||
* @param userEmail - Email do usuário (opcional)
|
|
||||||
* @returns Promise com sucesso ou erro
|
|
||||||
*/
|
*/
|
||||||
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
|
async downloadAudioFile(audioUrl: string, filename?: string): Promise<void> {
|
||||||
// Usa valores do GlobalFunctions se não forem fornecidos
|
const urlTrim = audioUrl?.trim();
|
||||||
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
if (!urlTrim) {
|
||||||
|
throw new Error('URL do áudio inválida');
|
||||||
if (!email) {
|
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'Email do usuário não fornecido',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audioId) {
|
const safeName = (filename || `audio_${Date.now()}.mp3`).replace(/[/\\?%*:|"<>]/g, '_');
|
||||||
throw {
|
|
||||||
success: false,
|
|
||||||
message: 'ID do áudio não fornecido',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Deletando áudio:', {
|
|
||||||
audioId,
|
|
||||||
userEmail: email,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let sameOrigin = false;
|
||||||
try {
|
try {
|
||||||
// Faz a requisição DELETE com parâmetros na URL
|
const u = new URL(urlTrim, typeof window !== 'undefined' ? window.location.href : undefined);
|
||||||
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
|
sameOrigin = typeof window !== 'undefined' && u.origin === window.location.origin;
|
||||||
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
|
} catch {
|
||||||
);
|
sameOrigin = false;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Resposta completa do DELETE:', response);
|
const fetchBlob = async (): Promise<Blob> => {
|
||||||
console.log('response.data:', response.data);
|
const response = await fetch(urlTrim, {
|
||||||
|
mode: 'cors',
|
||||||
// A API retorna um array com um objeto: [{"success":true}]
|
credentials: sameOrigin ? 'include' : 'omit',
|
||||||
let result: { success: boolean; message?: string };
|
cache: 'no-store',
|
||||||
|
});
|
||||||
if (Array.isArray(response.data)) {
|
if (!response.ok) {
|
||||||
// Se for array, pega o primeiro elemento
|
throw new Error(`Falha ao baixar (HTTP ${response.status})`);
|
||||||
result = response.data[0];
|
|
||||||
console.log('API retornou array, usando primeiro elemento:', result);
|
|
||||||
} else {
|
|
||||||
// Se for objeto direto
|
|
||||||
result = response.data;
|
|
||||||
console.log('API retornou objeto direto:', result);
|
|
||||||
}
|
}
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
// Garante que tem a estrutura mínima
|
const xhrBlob = (): Promise<Blob> =>
|
||||||
return {
|
new Promise((resolve, reject) => {
|
||||||
success: result.success ?? true,
|
const xhr = new XMLHttpRequest();
|
||||||
message: result.message || 'Áudio deletado com sucesso',
|
xhr.open('GET', urlTrim, true);
|
||||||
};
|
xhr.responseType = 'blob';
|
||||||
} catch (error: any) {
|
xhr.onload = () => {
|
||||||
console.error('Erro ao deletar áudio:', error);
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(xhr.response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Falha ao baixar (HTTP ${xhr.status})`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error('Falha de rede ao baixar o áudio'));
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
|
||||||
throw {
|
let blob: Blob;
|
||||||
success: false,
|
try {
|
||||||
message: error.message || 'Erro ao deletar áudio',
|
blob = await fetchBlob();
|
||||||
status: error.status,
|
} catch (e1) {
|
||||||
};
|
try {
|
||||||
|
blob = await xhrBlob();
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('downloadAudioFile:', e1, e2);
|
||||||
|
throw new Error(
|
||||||
|
'Não foi possível baixar o áudio. Se o arquivo estiver em outro domínio, é preciso CORS liberando GET para esta origem.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsMimeFix =
|
||||||
|
!blob.type ||
|
||||||
|
blob.type === 'application/octet-stream' ||
|
||||||
|
blob.type === 'text/html';
|
||||||
|
const typedBlob = needsMimeFix ? new Blob([blob], { type: 'audio/mpeg' }) : blob;
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(typedBlob);
|
||||||
|
try {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = safeName.endsWith('.mp3') ? safeName : `${safeName}.mp3`;
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exporta instância única (Singleton)
|
|
||||||
export const audioGenerationService = new AudioGenerationService();
|
export const audioGenerationService = new AudioGenerationService();
|
||||||
|
|||||||
+236
-791
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const COMMANDER_CSV_BASE_URL = "https://hmg-hgtx-n8n.hgtx.com.br/webhook/hgtxialocal";
|
||||||
|
|
||||||
|
const commanderCsvApi = axios.create({
|
||||||
|
baseURL: COMMANDER_CSV_BASE_URL,
|
||||||
|
timeout: 600_000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CommanderCsvMode = "pdf" | "text";
|
||||||
|
|
||||||
|
export const commanderCsvService = {
|
||||||
|
async healthcheck() {
|
||||||
|
const response = await commanderCsvApi.get("/healthcheck");
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async pdf2csv(file: File, headersCsv: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("headers", headersCsv);
|
||||||
|
|
||||||
|
const response = await commanderCsvApi.post("/pdf2csv", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async txt2csv(content: string, headersCsv: string) {
|
||||||
|
const response = await commanderCsvApi.post("/txt2csv", {
|
||||||
|
content,
|
||||||
|
headers: headersCsv,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type AsanaWorkspaceUser = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type AsanaWorkspaceUsersResponse = {
|
||||||
|
data: Array<{
|
||||||
|
gid: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoAsanaWorkspaceUsersService {
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
|
||||||
|
error.message ??
|
||||||
|
fallback) as string;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarUsuariosDoWorkspace(workspaceId: string): Promise<AsanaWorkspaceUser[]> {
|
||||||
|
if (!workspaceId.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<AsanaWorkspaceUsersResponse>(
|
||||||
|
`${baseUrl}/asana/workspaces/${encodeURIComponent(workspaceId)}/users`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (response.data.data ?? []).map((item) => ({
|
||||||
|
id: item.gid,
|
||||||
|
name: item.name?.trim() || item.email?.trim() || item.gid,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar usuários do workspace do Asana.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoAsanaWorkspaceUsersService = new FechamentoAsanaWorkspaceUsersService();
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
import { normalizeBancoPontosExtratoItem } from "./bancoPontos";
|
||||||
|
import { type Capabilities, type PapelUsuario, getCapabilitiesForPapel } from "./commanderCapabilities";
|
||||||
|
|
||||||
|
export type { Capabilities, PapelUsuario } from "./commanderCapabilities";
|
||||||
|
|
||||||
|
export type MeData = {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
papel: PapelUsuario;
|
||||||
|
parceiroId: string | null;
|
||||||
|
estaAtivo: boolean;
|
||||||
|
unidadeId: string;
|
||||||
|
capabilities: Capabilities;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapStatusData = {
|
||||||
|
requiresBootstrap: boolean;
|
||||||
|
estabelecimentoId: string;
|
||||||
|
unidade: { id: string; nome: string; estabelecimentoId: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapInitInput = {
|
||||||
|
estabelecimentoId: string;
|
||||||
|
unidadeNome: string;
|
||||||
|
adminNome: string;
|
||||||
|
adminEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeuParceiroPerfilData = {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
tipoPessoa: "fisica" | "juridica";
|
||||||
|
cpf: string | null;
|
||||||
|
cnpj: string | null;
|
||||||
|
email: string;
|
||||||
|
whatsapp: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AtualizarMeuParceiroPerfilInput = {
|
||||||
|
nome?: string;
|
||||||
|
cpf?: string | null;
|
||||||
|
cnpj?: string | null;
|
||||||
|
email?: string;
|
||||||
|
whatsapp?: string | null;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeuFechamentoResumoData = {
|
||||||
|
parceiroId: string;
|
||||||
|
competencias: Array<{
|
||||||
|
competenciaId: string;
|
||||||
|
mes: number;
|
||||||
|
ano: number;
|
||||||
|
statusCompetencia: "em_aberto" | "concluido";
|
||||||
|
fechamento: {
|
||||||
|
id: string;
|
||||||
|
status: "em_aberto" | "fechado";
|
||||||
|
pontuacaoTotalEntregue: number;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
pontuacaoPaga: number;
|
||||||
|
pontuacaoBanco: number;
|
||||||
|
fechadoEm: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
indicadores: {
|
||||||
|
totalPontos: number;
|
||||||
|
saldoFechamentos: number;
|
||||||
|
saldoBancoPontos: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeuBancoPontosExtratoData = {
|
||||||
|
parceiro: {
|
||||||
|
parceiroId: string;
|
||||||
|
nome: string;
|
||||||
|
codinome: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
fotoUrl: string | null;
|
||||||
|
saldo: number;
|
||||||
|
};
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
parceiroId: string;
|
||||||
|
fechamentoId: string | null;
|
||||||
|
tipo: "credito" | "debito";
|
||||||
|
quantidade: number;
|
||||||
|
descricao: string;
|
||||||
|
criadoEm: string;
|
||||||
|
competenciaMes: number | null;
|
||||||
|
competenciaAno: number | null;
|
||||||
|
}>;
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
paginaAtual: number;
|
||||||
|
totalPaginas: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeuParceiroPerfilResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuParceiroPerfilData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeuFechamentoResumoResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuFechamentoResumoData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeuBancoPontosExtratoResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuBancoPontosExtratoData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BootstrapStatusResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: BootstrapStatusData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BootstrapInitResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
unidadeId: string;
|
||||||
|
adminId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportarMeuBancoPontosExtratoResponse = {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
status?: number;
|
||||||
|
data?: {
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isApiError(value: unknown): value is ApiErrorShape {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthMeService {
|
||||||
|
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch {
|
||||||
|
return utf8Match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const regularMatch = value.match(/filename="?([^";]+)"?/i);
|
||||||
|
return regularMatch?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
(error.response?.data as ApiErrorShape["data"] | undefined)?.error?.message ??
|
||||||
|
error.message ??
|
||||||
|
fallback;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(usuarioEmail: string): Promise<MeData | null> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<MeResponse>(`${baseUrl}/me`, {
|
||||||
|
params: { usuarioEmail },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const row = response.data.data;
|
||||||
|
const capabilities = row.capabilities ?? getCapabilitiesForPapel(row.papel);
|
||||||
|
return { ...row, capabilities };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const code = (error.response?.data as ApiErrorShape["data"] | undefined)?.error?.code;
|
||||||
|
if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
(error.response?.data as ApiErrorShape["data"] | undefined)?.error?.message ?? error.message;
|
||||||
|
throw new Error(message || "Erro ao consultar acesso do usuário.");
|
||||||
|
}
|
||||||
|
if (isApiError(error)) {
|
||||||
|
const status = error.status;
|
||||||
|
const code = error.data?.error?.code;
|
||||||
|
if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const message = error.data?.error?.message ?? error.message;
|
||||||
|
throw new Error(message || "Erro ao consultar acesso do usuário.");
|
||||||
|
}
|
||||||
|
throw new Error("Erro ao consultar acesso do usuário.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBootstrapStatus(estabelecimentoId: string): Promise<BootstrapStatusData> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||||
|
const response = await axios.get<BootstrapStatusResponse>(`${baseUrl}/bootstrap-status`, {
|
||||||
|
headers,
|
||||||
|
params: { estabelecimentoId },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeuParceiroPerfil(usuarioEmail: string): Promise<MeuParceiroPerfilData> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
try {
|
||||||
|
const response = await axios.get<MeuParceiroPerfilResponse>(`${baseUrl}/me/parceiro-perfil`, {
|
||||||
|
headers,
|
||||||
|
params: { usuarioEmail },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao carregar perfil do parceiro.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchMeuParceiroPerfil(
|
||||||
|
usuarioEmail: string,
|
||||||
|
input: AtualizarMeuParceiroPerfilInput,
|
||||||
|
): Promise<MeuParceiroPerfilData> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
try {
|
||||||
|
const response = await axios.patch<MeuParceiroPerfilResponse>(`${baseUrl}/me/parceiro-perfil`, input, {
|
||||||
|
headers,
|
||||||
|
params: { usuarioEmail },
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao atualizar perfil do parceiro.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeuFechamento(usuarioEmail: string, ano?: number): Promise<MeuFechamentoResumoData> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
try {
|
||||||
|
const response = await axios.get<MeuFechamentoResumoResponse>(`${baseUrl}/me/fechamentos`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
usuarioEmail,
|
||||||
|
ano,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao carregar meu fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMeuBancoPontosExtrato(
|
||||||
|
usuarioEmail: string,
|
||||||
|
page: number,
|
||||||
|
perPage: number,
|
||||||
|
): Promise<MeuBancoPontosExtratoData> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
try {
|
||||||
|
const response = await axios.get<MeuBancoPontosExtratoResponse>(`${baseUrl}/me/banco-pontos/extrato`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
usuarioEmail,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = response.data.data;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
data: (payload.data ?? []).map((row) =>
|
||||||
|
normalizeBancoPontosExtratoItem(row as unknown as Record<string, unknown>),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportarMeuBancoPontosExtrato(usuarioEmail: string): Promise<ExportarMeuBancoPontosExtratoResponse> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ArrayBuffer>(`${baseUrl}/me/banco-pontos/extrato/exportar`, {
|
||||||
|
headers,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
params: { usuarioEmail },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
buffer: response.data,
|
||||||
|
filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrapInitialize(input: BootstrapInitInput): Promise<{ unidadeId: string; adminId: string }> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||||
|
const response = await axios.post<BootstrapInitResponse>(`${baseUrl}/bootstrap`, input, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMeService = new AuthMeService();
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type BancoPontosEstaAtivoFiltro = "all" | "true" | "false";
|
||||||
|
|
||||||
|
export type BancoPontosSaldoItem = {
|
||||||
|
parceiroId: string;
|
||||||
|
nome: string;
|
||||||
|
codinome: string | null;
|
||||||
|
estaAtivo: boolean;
|
||||||
|
logoUrl: string | null;
|
||||||
|
/** URL pública da foto (Commander pode enviar junto com `logoUrl`). */
|
||||||
|
fotoUrl?: string | null;
|
||||||
|
saldo: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BancoPontosExtratoItem = {
|
||||||
|
id: string;
|
||||||
|
parceiroId: string;
|
||||||
|
fechamentoId: string | null;
|
||||||
|
tipo: "credito" | "debito";
|
||||||
|
quantidade: number;
|
||||||
|
descricao: string;
|
||||||
|
criadoEm: string;
|
||||||
|
/** Mês da competência do fechamento (1–12), se a linha estiver vinculada a um fechamento. */
|
||||||
|
competenciaMes: number | null;
|
||||||
|
/** Ano da competência do fechamento, se houver. */
|
||||||
|
competenciaAno: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BancoPontosParceiroExtrato = {
|
||||||
|
parceiroId: string;
|
||||||
|
nome: string;
|
||||||
|
codinome: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
fotoUrl?: string | null;
|
||||||
|
saldo: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetaResponse = {
|
||||||
|
total: number;
|
||||||
|
paginaAtual: number;
|
||||||
|
totalPaginas: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarBancoPontosSaldosResponse = {
|
||||||
|
data: BancoPontosSaldoItem[];
|
||||||
|
meta: MetaResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarBancoPontosExtratoResponse = {
|
||||||
|
parceiro: BancoPontosParceiroExtrato;
|
||||||
|
data: BancoPontosExtratoItem[];
|
||||||
|
meta: MetaResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickLogoUrl(row: { logoUrl?: unknown; logo_url?: unknown }): string | null {
|
||||||
|
const v = row.logoUrl ?? row.logo_url;
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s.length > 0 ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSaldo(row: { saldo?: unknown; saldo_atual?: unknown }): number {
|
||||||
|
const v = row.saldo ?? row.saldo_atual;
|
||||||
|
if (v == null || v === "") return 0;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFotoUrl(raw: { fotoUrl?: unknown; foto_url?: unknown }): string | null {
|
||||||
|
const v = raw.fotoUrl ?? raw.foto_url;
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s.length > 0 ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSaldoItem(raw: Record<string, unknown>): BancoPontosSaldoItem {
|
||||||
|
const logo = pickLogoUrl(raw);
|
||||||
|
const foto = pickFotoUrl(raw) ?? logo;
|
||||||
|
return {
|
||||||
|
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||||||
|
nome: String(raw.nome ?? ""),
|
||||||
|
codinome: raw.codinome != null ? String(raw.codinome) : null,
|
||||||
|
estaAtivo: Boolean(raw.estaAtivo ?? raw.esta_ativo),
|
||||||
|
logoUrl: foto,
|
||||||
|
fotoUrl: foto,
|
||||||
|
saldo: pickSaldo(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeParceiroExtrato(raw: Record<string, unknown>): BancoPontosParceiroExtrato {
|
||||||
|
const logo = pickLogoUrl(raw);
|
||||||
|
const foto = pickFotoUrl(raw) ?? logo;
|
||||||
|
return {
|
||||||
|
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||||||
|
nome: String(raw.nome ?? ""),
|
||||||
|
codinome: raw.codinome != null ? String(raw.codinome) : null,
|
||||||
|
logoUrl: foto,
|
||||||
|
fotoUrl: foto,
|
||||||
|
saldo: pickSaldo(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNullableInt(v: unknown): number | null {
|
||||||
|
if (v == null || v === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBancoPontosExtratoItem(raw: Record<string, unknown>): BancoPontosExtratoItem {
|
||||||
|
const fechamentoRaw = raw.fechamentoId ?? raw.fechamento_id;
|
||||||
|
return {
|
||||||
|
id: String(raw.id ?? ""),
|
||||||
|
parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
|
||||||
|
fechamentoId: fechamentoRaw != null && String(fechamentoRaw).trim() !== "" ? String(fechamentoRaw) : null,
|
||||||
|
tipo: raw.tipo === "debito" ? "debito" : "credito",
|
||||||
|
quantidade: Number(raw.quantidade ?? 0),
|
||||||
|
descricao: String(raw.descricao ?? ""),
|
||||||
|
criadoEm: String(raw.criadoEm ?? raw.criado_em ?? ""),
|
||||||
|
competenciaMes: pickNullableInt(raw.competenciaMes ?? raw.competencia_mes),
|
||||||
|
competenciaAno: pickNullableInt(raw.competenciaAno ?? raw.competencia_ano),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListarBancoPontosSaldosParams = {
|
||||||
|
estaAtivo?: BancoPontosEstaAtivoFiltro;
|
||||||
|
busca?: string;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarBancoPontosExtratoParams = {
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportarBancoPontosExtratoResponse = {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoBancoPontosService {
|
||||||
|
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch {
|
||||||
|
return utf8Match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const regularMatch = value.match(/filename="?([^";]+)"?/i);
|
||||||
|
return regularMatch?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageFromResponseData(data: unknown): string | null {
|
||||||
|
if (data == null) return null;
|
||||||
|
if (typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
|
||||||
|
const msg = (data as ApiErrorShape).error?.message;
|
||||||
|
return typeof msg === "string" && msg.trim() ? msg : null;
|
||||||
|
}
|
||||||
|
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
||||||
|
const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
if (buf.byteLength === 0) return null;
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(buf);
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
const json = JSON.parse(trimmed) as ApiErrorShape;
|
||||||
|
if (typeof json.error?.message === "string" && json.error.message.trim()) {
|
||||||
|
return json.error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = error.response.status;
|
||||||
|
const fromBody =
|
||||||
|
this.messageFromResponseData(error.response.data) ??
|
||||||
|
(status === 404
|
||||||
|
? "Recurso não encontrado. Confirme se o backend está atualizado (rota de exportação)."
|
||||||
|
: null);
|
||||||
|
const message = (fromBody ?? error.message ?? fallback) as string;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarSaldos(params: ListarBancoPontosSaldosParams): Promise<ListarBancoPontosSaldosResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarBancoPontosSaldosResponse>(`${baseUrl}/banco-pontos`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
estaAtivo: params.estaAtivo ?? "all",
|
||||||
|
busca: params.busca?.trim() || undefined,
|
||||||
|
page: params.page,
|
||||||
|
perPage: params.perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = response.data;
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
data: (body.data ?? []).map((row) => normalizeSaldoItem(row as unknown as Record<string, unknown>)),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar saldos do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarExtrato(
|
||||||
|
parceiroId: string,
|
||||||
|
params: ListarBancoPontosExtratoParams,
|
||||||
|
): Promise<ListarBancoPontosExtratoResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarBancoPontosExtratoResponse>(
|
||||||
|
`${baseUrl}/banco-pontos/${parceiroId}/extrato`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
page: params.page,
|
||||||
|
perPage: params.perPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const body = response.data;
|
||||||
|
const parceiroRaw = body.parceiro as unknown;
|
||||||
|
const parceiroNorm =
|
||||||
|
parceiroRaw != null && typeof parceiroRaw === "object"
|
||||||
|
? normalizeParceiroExtrato(parceiroRaw as Record<string, unknown>)
|
||||||
|
: body.parceiro;
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
parceiro: parceiroNorm,
|
||||||
|
data: (body.data ?? []).map((row) => normalizeBancoPontosExtratoItem(row as unknown as Record<string, unknown>)),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportarExtrato(parceiroId: string): Promise<ExportarBancoPontosExtratoResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ArrayBuffer>(
|
||||||
|
`${baseUrl}/banco-pontos/${parceiroId}/extrato/exportar`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const buf = response.data;
|
||||||
|
if (buf.byteLength < 4) {
|
||||||
|
throw new Error("Resposta vazia ao exportar planilha.");
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
const headText = new TextDecoder().decode(bytes.subarray(0, Math.min(256, bytes.length))).trim();
|
||||||
|
if (headText.startsWith("{")) {
|
||||||
|
let json: ApiErrorShape;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(new TextDecoder().decode(bytes)) as ApiErrorShape;
|
||||||
|
} catch {
|
||||||
|
throw new Error("A API retornou uma resposta inválida ao exportar.");
|
||||||
|
}
|
||||||
|
const msg = json.error?.message?.trim();
|
||||||
|
throw new Error(msg || "Falha ao exportar extrato (resposta JSON). Atualize o Commander.");
|
||||||
|
}
|
||||||
|
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
|
||||||
|
throw new Error("A API não retornou um arquivo XLSX válido (assinatura inválida).");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
buffer: buf,
|
||||||
|
filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos.");
|
||||||
|
}
|
||||||
|
throw error instanceof Error ? error : new Error("Erro ao exportar extrato do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async excluirTransacao(transacaoId: string, motivo: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
await axios.delete(`${baseUrl}/banco-pontos/transacoes/${transacaoId}`, {
|
||||||
|
headers,
|
||||||
|
data: { motivo: motivo.trim() },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao excluir lançamento do banco de pontos.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoBancoPontosService = new FechamentoBancoPontosService();
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/** Espelha `OPEN_COMMANDER_BACKEND/src/security/capabilities.ts` para fallback e tipagem no app. */
|
||||||
|
export type PapelUsuario = "admin" | "parceiro" | "supervisor";
|
||||||
|
|
||||||
|
export type Capabilities = {
|
||||||
|
competencias: {
|
||||||
|
listar: boolean;
|
||||||
|
criar: boolean;
|
||||||
|
excluir: boolean;
|
||||||
|
concluir: boolean;
|
||||||
|
reabrir: boolean;
|
||||||
|
importarAsana: boolean;
|
||||||
|
};
|
||||||
|
fechamentos: {
|
||||||
|
exportarPlanilha: boolean;
|
||||||
|
exportarPdf: boolean;
|
||||||
|
concluir: boolean;
|
||||||
|
reabrir: boolean;
|
||||||
|
excluir: boolean;
|
||||||
|
reprocessarAsana: boolean;
|
||||||
|
tarefaAlterarCompetencia: boolean;
|
||||||
|
tarefaLancarAjuste: boolean;
|
||||||
|
tarefaEditarRevisao: boolean;
|
||||||
|
tarefaExcluirManual: boolean;
|
||||||
|
};
|
||||||
|
bancoPontos: { acessoAdmin: boolean; excluirTransacao: boolean };
|
||||||
|
parceiros: {
|
||||||
|
listar: boolean;
|
||||||
|
criar: boolean;
|
||||||
|
editar: boolean;
|
||||||
|
inativarToggle: boolean;
|
||||||
|
verFator: boolean;
|
||||||
|
editarFator: boolean;
|
||||||
|
};
|
||||||
|
usuarios: { listar: boolean; criarEditarToggle: boolean; excluir: boolean };
|
||||||
|
configuracoes: { salvar: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
function adminCapabilities(): Capabilities {
|
||||||
|
return {
|
||||||
|
competencias: {
|
||||||
|
listar: true,
|
||||||
|
criar: true,
|
||||||
|
excluir: true,
|
||||||
|
concluir: true,
|
||||||
|
reabrir: true,
|
||||||
|
importarAsana: true,
|
||||||
|
},
|
||||||
|
fechamentos: {
|
||||||
|
exportarPlanilha: true,
|
||||||
|
exportarPdf: true,
|
||||||
|
concluir: true,
|
||||||
|
reabrir: true,
|
||||||
|
excluir: true,
|
||||||
|
reprocessarAsana: true,
|
||||||
|
tarefaAlterarCompetencia: true,
|
||||||
|
tarefaLancarAjuste: true,
|
||||||
|
tarefaEditarRevisao: true,
|
||||||
|
tarefaExcluirManual: true,
|
||||||
|
},
|
||||||
|
bancoPontos: { acessoAdmin: true, excluirTransacao: true },
|
||||||
|
parceiros: {
|
||||||
|
listar: true,
|
||||||
|
criar: true,
|
||||||
|
editar: true,
|
||||||
|
inativarToggle: true,
|
||||||
|
verFator: true,
|
||||||
|
editarFator: true,
|
||||||
|
},
|
||||||
|
usuarios: { listar: true, criarEditarToggle: true, excluir: true },
|
||||||
|
configuracoes: { salvar: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parceiroCapabilities(): Capabilities {
|
||||||
|
return {
|
||||||
|
competencias: {
|
||||||
|
listar: false,
|
||||||
|
criar: false,
|
||||||
|
excluir: false,
|
||||||
|
concluir: false,
|
||||||
|
reabrir: false,
|
||||||
|
importarAsana: false,
|
||||||
|
},
|
||||||
|
fechamentos: {
|
||||||
|
exportarPlanilha: false,
|
||||||
|
exportarPdf: false,
|
||||||
|
concluir: false,
|
||||||
|
reabrir: false,
|
||||||
|
excluir: false,
|
||||||
|
reprocessarAsana: false,
|
||||||
|
tarefaAlterarCompetencia: false,
|
||||||
|
tarefaLancarAjuste: false,
|
||||||
|
tarefaEditarRevisao: false,
|
||||||
|
tarefaExcluirManual: false,
|
||||||
|
},
|
||||||
|
bancoPontos: { acessoAdmin: false, excluirTransacao: false },
|
||||||
|
parceiros: {
|
||||||
|
listar: false,
|
||||||
|
criar: false,
|
||||||
|
editar: false,
|
||||||
|
inativarToggle: false,
|
||||||
|
verFator: false,
|
||||||
|
editarFator: false,
|
||||||
|
},
|
||||||
|
usuarios: { listar: false, criarEditarToggle: false, excluir: false },
|
||||||
|
configuracoes: { salvar: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function supervisorCapabilities(): Capabilities {
|
||||||
|
return {
|
||||||
|
competencias: {
|
||||||
|
listar: true,
|
||||||
|
criar: false,
|
||||||
|
excluir: false,
|
||||||
|
concluir: false,
|
||||||
|
reabrir: false,
|
||||||
|
importarAsana: false,
|
||||||
|
},
|
||||||
|
fechamentos: {
|
||||||
|
exportarPlanilha: true,
|
||||||
|
exportarPdf: true,
|
||||||
|
concluir: false,
|
||||||
|
reabrir: false,
|
||||||
|
excluir: false,
|
||||||
|
reprocessarAsana: false,
|
||||||
|
tarefaAlterarCompetencia: false,
|
||||||
|
tarefaLancarAjuste: false,
|
||||||
|
tarefaEditarRevisao: false,
|
||||||
|
tarefaExcluirManual: false,
|
||||||
|
},
|
||||||
|
bancoPontos: { acessoAdmin: true, excluirTransacao: false },
|
||||||
|
parceiros: {
|
||||||
|
listar: true,
|
||||||
|
criar: true,
|
||||||
|
editar: true,
|
||||||
|
inativarToggle: true,
|
||||||
|
verFator: false,
|
||||||
|
editarFator: false,
|
||||||
|
},
|
||||||
|
usuarios: { listar: true, criarEditarToggle: false, excluir: false },
|
||||||
|
configuracoes: { salvar: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCapabilitiesForPapel(papel: string): Capabilities {
|
||||||
|
if (papel === "admin") return adminCapabilities();
|
||||||
|
if (papel === "supervisor") return supervisorCapabilities();
|
||||||
|
return parceiroCapabilities();
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
|
||||||
|
import { resolveCommanderUnidadeId } from "./unidadeContext";
|
||||||
|
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||||
|
|
||||||
|
export { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||||
|
|
||||||
|
export type BuildCommanderHeadersOptions = {
|
||||||
|
/** Rotas allowlisted no Commander (ex.: `/me`, lookup de `/unidades`) não devem enviar `X-Unidade-Id`. */
|
||||||
|
omitUnidadeId?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveUsuarioEmailForCommanderHeader(): string | undefined {
|
||||||
|
let email = GlobalFunctions.getUsuarioLogado().email?.trim();
|
||||||
|
if (!email) {
|
||||||
|
const transfer = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||||
|
if (typeof transfer === "string" && transfer.trim()) {
|
||||||
|
email = transfer.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return email || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCommanderHeaders(
|
||||||
|
opts?: BuildCommanderHeadersOptions,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const token = await GlobalFunctions.getToken();
|
||||||
|
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(apiKey ? { apikey: apiKey } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
const usuarioEmail = resolveUsuarioEmailForCommanderHeader();
|
||||||
|
if (usuarioEmail) {
|
||||||
|
headers["X-Usuario-Email"] = usuarioEmail;
|
||||||
|
}
|
||||||
|
if (!opts?.omitUnidadeId) {
|
||||||
|
headers["X-Unidade-Id"] = await resolveCommanderUnidadeId();
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type CompetenciaStatus = "em_aberto" | "concluido";
|
||||||
|
|
||||||
|
export type CompetenciaItem = {
|
||||||
|
id: string;
|
||||||
|
mes: number;
|
||||||
|
ano: number;
|
||||||
|
status: CompetenciaStatus;
|
||||||
|
quantidadeFechamentos: number;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FechamentoDaCompetenciaItem = {
|
||||||
|
id: string;
|
||||||
|
competenciaId: string;
|
||||||
|
parceiroId: string;
|
||||||
|
parceiroNome: string;
|
||||||
|
parceiroCodinome: string | null;
|
||||||
|
parceiroLogoUrl: string | null;
|
||||||
|
parceiroFator?: number | null;
|
||||||
|
horasTotal: number;
|
||||||
|
status: "em_aberto" | "fechado";
|
||||||
|
versao: number;
|
||||||
|
pontuacaoTotalEntregue: number;
|
||||||
|
pontuacaoAprovada: number;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
pontuacaoPaga: number;
|
||||||
|
pontuacaoBanco: number;
|
||||||
|
exportadoFinanceiro: boolean;
|
||||||
|
fechadoPorId: string | null;
|
||||||
|
fechadoEm: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListarCompetenciasResponse = {
|
||||||
|
data: CompetenciaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListarFechamentosDaCompetenciaResponse = {
|
||||||
|
data: FechamentoDaCompetenciaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CriarCompetenciaResponse = {
|
||||||
|
data: CompetenciaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConcluirCompetenciaResponse = {
|
||||||
|
data: CompetenciaItem & {
|
||||||
|
concluidoPorNome: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReabrirCompetenciaResponse = {
|
||||||
|
data: CompetenciaItem & {
|
||||||
|
reabertoPorNome: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportarCompetenciaResponse = {
|
||||||
|
data: {
|
||||||
|
competenciaId: string;
|
||||||
|
tarefasRecebidas: number;
|
||||||
|
tarefasImportadas: number;
|
||||||
|
fechamentosCriados: number;
|
||||||
|
tarefasIgnoradasSemParceiro: number;
|
||||||
|
chamadasSubtarefasAsana: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportacaoAsanaModo =
|
||||||
|
| "reprocessar_tudo"
|
||||||
|
| "reprocessar_alguns"
|
||||||
|
| "buscar_novos_fechamentos";
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoCompetenciasService {
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
|
||||||
|
error.message ??
|
||||||
|
fallback) as string;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarCompetencias(params: { ano?: number; status?: CompetenciaStatus }): Promise<CompetenciaItem[]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarCompetenciasResponse>(`${baseUrl}/competencias`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
ano: params.ano,
|
||||||
|
status: params.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar competências.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarFechamentosDaCompetencia(competenciaId: string): Promise<FechamentoDaCompetenciaItem[]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarFechamentosDaCompetenciaResponse>(
|
||||||
|
`${baseUrl}/competencias/${competenciaId}/fechamentos`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar fechamentos da competência.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async criarCompetencia(input: { mes: number; ano: number }): Promise<CompetenciaItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<CriarCompetenciaResponse>(
|
||||||
|
`${baseUrl}/competencias`,
|
||||||
|
{ mes: input.mes, ano: input.ano },
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao criar competência.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async concluirCompetencia(competenciaId: string, concluidoPorId: string): Promise<ConcluirCompetenciaResponse["data"]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ConcluirCompetenciaResponse>(
|
||||||
|
`${baseUrl}/competencias/${competenciaId}/concluir`,
|
||||||
|
{ concluido_por_id: concluidoPorId },
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao concluir competência.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reabrirCompetencia(
|
||||||
|
competenciaId: string,
|
||||||
|
reabertoPorId: string,
|
||||||
|
motivo: string,
|
||||||
|
): Promise<ReabrirCompetenciaResponse["data"]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ReabrirCompetenciaResponse>(
|
||||||
|
`${baseUrl}/competencias/${competenciaId}/reabrir`,
|
||||||
|
{ reaberto_por_id: reabertoPorId, motivo: motivo.trim() },
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao reabrir competência.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async excluirCompetencia(competenciaId: string, excluidoPorId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
await axios.delete(`${baseUrl}/competencias/${competenciaId}`, {
|
||||||
|
headers,
|
||||||
|
data: { excluido_por_id: excluidoPorId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao excluir competência.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async importarDoAsana(
|
||||||
|
competenciaId: string,
|
||||||
|
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
|
||||||
|
): Promise<{ tarefasRecebidas: number; tarefasImportadas: number; fechamentosCriados: number; tarefasIgnoradasSemParceiro: number }> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ImportarCompetenciaResponse>(
|
||||||
|
`${baseUrl}/competencias/${competenciaId}/importar`,
|
||||||
|
{
|
||||||
|
modo: opcoes?.modo ?? "reprocessar_tudo",
|
||||||
|
parceiroIds: opcoes?.parceiroIds,
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tarefasRecebidas: response.data.data.tarefasRecebidas,
|
||||||
|
tarefasImportadas: response.data.data.tarefasImportadas,
|
||||||
|
fechamentosCriados: response.data.data.fechamentosCriados,
|
||||||
|
tarefasIgnoradasSemParceiro: response.data.data.tarefasIgnoradasSemParceiro,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao importar tasks do Asana.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoCompetenciasService = new FechamentoCompetenciasService();
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type ConfiguracaoPublica = {
|
||||||
|
id: string;
|
||||||
|
asanaWorkspaceId: string | null;
|
||||||
|
asanaWorkspaceNome: string | null;
|
||||||
|
asanaToken: string | null;
|
||||||
|
asanaTokenConfigured: boolean;
|
||||||
|
empresaRazaoSocial: string | null;
|
||||||
|
empresaCnpj: string | null;
|
||||||
|
empresaLogoUrl: string | null;
|
||||||
|
atualizadoEm: string;
|
||||||
|
atualizadoPorId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetConfiguracoesResponse = {
|
||||||
|
data: ConfiguracaoPublica | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SalvarConfiguracoesBody = {
|
||||||
|
asanaToken?: string;
|
||||||
|
asanaWorkspaceId?: string | null;
|
||||||
|
asanaWorkspaceNome?: string | null;
|
||||||
|
empresaRazaoSocial?: string | null;
|
||||||
|
empresaCnpj?: string | null;
|
||||||
|
empresaLogoUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SalvarConfiguracoesResponse = {
|
||||||
|
data: ConfiguracaoPublica;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoConfiguracoesService {
|
||||||
|
async getConfiguracoes(): Promise<ConfiguracaoPublica | null> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<GetConfiguracoesResponse>(`${baseUrl}/configuracoes`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
((error.response.data as { error?: { message?: string } } | undefined)?.error?.message ??
|
||||||
|
error.message) ||
|
||||||
|
"Erro ao carregar configurações.";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error("Erro ao carregar configurações.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async salvarConfiguracoes(body: SalvarConfiguracoesBody): Promise<ConfiguracaoPublica> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<SalvarConfiguracoesResponse>(
|
||||||
|
`${baseUrl}/configuracoes`,
|
||||||
|
body,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
((error.response.data as { error?: { message?: string } } | undefined)?.error?.message ??
|
||||||
|
error.message) ||
|
||||||
|
"Erro ao salvar configurações.";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error("Erro ao salvar configurações.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoConfiguracoesService = new FechamentoConfiguracoesService();
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type DashboardPontosClienteLinha = {
|
||||||
|
cliente: string;
|
||||||
|
pontos: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPontosPorParceiro = {
|
||||||
|
parceiroId: string;
|
||||||
|
parceiroNome: string;
|
||||||
|
linhas: DashboardPontosClienteLinha[];
|
||||||
|
totalParceiro: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RelatorioResponse = {
|
||||||
|
data: DashboardPontosPorParceiro[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RelatorioPontosPorClienteParams = {
|
||||||
|
dataInicio?: string;
|
||||||
|
dataFim?: string;
|
||||||
|
/** Um único parceiro (legado; preferir `parceiroIds`). */
|
||||||
|
parceiroId?: string;
|
||||||
|
/** Vários parceiros (CSV na query). Se vazio/ausente com `parceiroId`, usa só o id único. */
|
||||||
|
parceiroIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
class DashboardPontosPorClienteService {
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
|
||||||
|
error.message ??
|
||||||
|
fallback) as string;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async relatorioPontosPorCliente(params: RelatorioPontosPorClienteParams): Promise<DashboardPontosPorParceiro[]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<RelatorioResponse>(
|
||||||
|
`${baseUrl}/fechamentos/relatorio/pontos-por-cliente`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
data_inicio: params.dataInicio?.trim() || undefined,
|
||||||
|
data_fim: params.dataFim?.trim() || undefined,
|
||||||
|
parceiro_ids:
|
||||||
|
params.parceiroIds && params.parceiroIds.length > 0
|
||||||
|
? params.parceiroIds.join(",")
|
||||||
|
: undefined,
|
||||||
|
parceiro_id:
|
||||||
|
(!params.parceiroIds || params.parceiroIds.length === 0) && params.parceiroId?.trim()
|
||||||
|
? params.parceiroId.trim()
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao carregar relatório de pontos por cliente.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardPontosPorClienteService = new DashboardPontosPorClienteService();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function resolveCommanderBaseUrl(): string {
|
||||||
|
const raw = import.meta.env.VITE_API_BASE_URL_COMMANDER?.trim();
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error(
|
||||||
|
"A variável VITE_API_BASE_URL_COMMANDER não está configurada. Defina, por exemplo, http://localhost:3333/api/",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type FechamentoTarefaItem = {
|
||||||
|
id: string;
|
||||||
|
fechamentoId: string;
|
||||||
|
asanaTaskGid?: string | null;
|
||||||
|
tipo: string;
|
||||||
|
numeroTicket: string | null;
|
||||||
|
descricao: string;
|
||||||
|
cliente: string | null;
|
||||||
|
linkAsana: string | null;
|
||||||
|
etiquetas: { gid: string; name: string }[] | null;
|
||||||
|
tempoMinutos: number | null;
|
||||||
|
pontuacao: number;
|
||||||
|
pontuacaoOriginal: number;
|
||||||
|
estaRevisada: boolean;
|
||||||
|
dataInicio: string | null;
|
||||||
|
dataVencimento: string | null;
|
||||||
|
dataConclusao: string | null;
|
||||||
|
editadoEm: string | null;
|
||||||
|
editadoPorId: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListarTarefasResponse = {
|
||||||
|
data: FechamentoTarefaItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PatchTarefaResponse = {
|
||||||
|
data: FechamentoTarefaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CriarLancamentoResponse = {
|
||||||
|
data: FechamentoTarefaItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FechamentoFechadoResponse = {
|
||||||
|
id: string;
|
||||||
|
competenciaId: string;
|
||||||
|
parceiroId: string;
|
||||||
|
status: "fechado";
|
||||||
|
versao: number;
|
||||||
|
pontuacaoTotalEntregue: number;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
pontuacaoPaga: number;
|
||||||
|
pontuacaoBanco: number;
|
||||||
|
exportadoFinanceiro: boolean;
|
||||||
|
fechadoPorId: string | null;
|
||||||
|
fechadoEm: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FechamentoReabertoResponse = {
|
||||||
|
id: string;
|
||||||
|
competenciaId: string;
|
||||||
|
parceiroId: string;
|
||||||
|
status: "em_aberto";
|
||||||
|
versao: number;
|
||||||
|
pontuacaoTotalEntregue: number;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
pontuacaoPaga: number;
|
||||||
|
pontuacaoBanco: number;
|
||||||
|
exportadoFinanceiro: boolean;
|
||||||
|
fechadoPorId: string | null;
|
||||||
|
fechadoEm: string | null;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConcluirFechamentoResponse = {
|
||||||
|
data: FechamentoFechadoResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReabrirFechamentoResponse = {
|
||||||
|
data: FechamentoReabertoResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReprocessarAsanaResponse = {
|
||||||
|
data: {
|
||||||
|
fechamentoId: string;
|
||||||
|
tarefasRecebidas: number;
|
||||||
|
tarefasImportadas: number;
|
||||||
|
tarefasIgnoradasSemParceiro: number;
|
||||||
|
tarefasRemovidasForaCompetencia: number;
|
||||||
|
chamadasSubtarefasAsana: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportarPlanilhaResponse = {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoFechamentosService {
|
||||||
|
private normalizeEtiquetas(
|
||||||
|
value: FechamentoTarefaItem["etiquetas"] | undefined,
|
||||||
|
tarefaId: string,
|
||||||
|
): { gid: string; name: string }[] | null {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const out: { gid: string; name: string }[] = [];
|
||||||
|
for (let i = 0; i < value.length; i += 1) {
|
||||||
|
const item = value[i];
|
||||||
|
const name = typeof item?.name === "string" ? item.name.trim() : "";
|
||||||
|
if (!name) continue;
|
||||||
|
const gidRaw = typeof item?.gid === "string" ? item.gid.trim() : "";
|
||||||
|
out.push({ gid: gidRaw || `${tarefaId}-etiqueta-${i}`, name });
|
||||||
|
}
|
||||||
|
return out.length > 0 ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const utf8Match = value.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/i);
|
||||||
|
if (utf8Match?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1].trim());
|
||||||
|
} catch {
|
||||||
|
return utf8Match[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const regularMatch = value.match(/filename\s*=\s*"([^"]+)"/i) ?? value.match(/filename\s*=\s*([^;\s]+)/i);
|
||||||
|
return regularMatch?.[1]?.trim() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private contentDispositionFromHeaders(headers: unknown): string | undefined {
|
||||||
|
if (!headers || typeof headers !== "object") return undefined;
|
||||||
|
const h = headers as { get?: (name: string) => string | undefined };
|
||||||
|
const fromGet = h.get?.("content-disposition") ?? h.get?.("Content-Disposition");
|
||||||
|
if (fromGet) return fromGet;
|
||||||
|
const rec = headers as Record<string, string | undefined>;
|
||||||
|
return rec["content-disposition"] ?? rec["Content-Disposition"];
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError(error: unknown, fallback: string): never {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (!error.response) {
|
||||||
|
throw new Error(
|
||||||
|
"Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
|
||||||
|
error.message ??
|
||||||
|
fallback) as string;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
throw new Error(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarTarefas(fechamentoId: string): Promise<FechamentoTarefaItem[]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarTarefasResponse>(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const tarefas = response.data.data ?? [];
|
||||||
|
return tarefas.map((tarefa) => ({
|
||||||
|
...tarefa,
|
||||||
|
etiquetas: this.normalizeEtiquetas(tarefa.etiquetas, tarefa.id),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar tarefas do fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchTarefa(
|
||||||
|
fechamentoId: string,
|
||||||
|
tarefaId: string,
|
||||||
|
input: {
|
||||||
|
estaRevisada?: boolean;
|
||||||
|
pontuacao?: number;
|
||||||
|
numeroTicket?: string | null;
|
||||||
|
descricao?: string;
|
||||||
|
cliente?: string | null;
|
||||||
|
tempoMinutos?: number | null;
|
||||||
|
editadoPorId: string;
|
||||||
|
},
|
||||||
|
): Promise<FechamentoTarefaItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.patch<PatchTarefaResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
|
||||||
|
{
|
||||||
|
...(input.estaRevisada !== undefined ? { esta_revisada: input.estaRevisada } : {}),
|
||||||
|
...(input.pontuacao !== undefined ? { pontuacao: input.pontuacao } : {}),
|
||||||
|
...(input.numeroTicket !== undefined ? { numero_ticket: input.numeroTicket } : {}),
|
||||||
|
...(input.descricao !== undefined ? { descricao: input.descricao } : {}),
|
||||||
|
...(input.cliente !== undefined ? { cliente: input.cliente } : {}),
|
||||||
|
...(input.tempoMinutos !== undefined ? { tempo_minutos: input.tempoMinutos } : {}),
|
||||||
|
editado_por_id: input.editadoPorId,
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao atualizar aprovação da tarefa.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async criarLancamento(
|
||||||
|
fechamentoId: string,
|
||||||
|
input:
|
||||||
|
| { tipo: "bonus" | "desconto"; descricao: string; pontuacao: number }
|
||||||
|
| { tipo: "bonus" | "desconto"; descricao: string; valorReal: number },
|
||||||
|
): Promise<FechamentoTarefaItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const body =
|
||||||
|
"valorReal" in input
|
||||||
|
? {
|
||||||
|
tipo: input.tipo,
|
||||||
|
descricao: input.descricao,
|
||||||
|
valorReal: input.valorReal,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tipo: input.tipo,
|
||||||
|
descricao: input.descricao,
|
||||||
|
pontuacao: input.pontuacao,
|
||||||
|
};
|
||||||
|
const response = await axios.post<CriarLancamentoResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/tarefas`,
|
||||||
|
body,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao criar lançamento no fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async excluirLancamento(fechamentoId: string, tarefaId: string): Promise<FechamentoTarefaItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.delete<PatchTarefaResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao excluir lançamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async concluirFechamento(
|
||||||
|
fechamentoId: string,
|
||||||
|
input: { pontuacaoPaga: number; fechadoPorId?: string; motivoAjuste?: string },
|
||||||
|
): Promise<FechamentoFechadoResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ConcluirFechamentoResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/concluir`,
|
||||||
|
{
|
||||||
|
pontuacao_paga: input.pontuacaoPaga,
|
||||||
|
...(input.fechadoPorId ? { fechado_por_id: input.fechadoPorId } : {}),
|
||||||
|
...(input.motivoAjuste?.trim() ? { motivo_ajuste: input.motivoAjuste.trim() } : {}),
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao concluir fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportarPlanilha(fechamentoId: string): Promise<ExportarPlanilhaResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ArrayBuffer>(`${baseUrl}/fechamentos/${fechamentoId}/exportar`, {
|
||||||
|
headers,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
buffer: response.data,
|
||||||
|
filename: this.extractFilenameFromContentDisposition(
|
||||||
|
this.contentDispositionFromHeaders(response.headers),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao exportar planilha do fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportarPdf(fechamentoId: string): Promise<ExportarPlanilhaResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ArrayBuffer>(`${baseUrl}/fechamentos/${fechamentoId}/exportar-pdf`, {
|
||||||
|
headers,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
buffer: response.data,
|
||||||
|
filename: this.extractFilenameFromContentDisposition(
|
||||||
|
this.contentDispositionFromHeaders(response.headers),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao exportar PDF do fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reabrirFechamento(
|
||||||
|
fechamentoId: string,
|
||||||
|
input: { reabertoPorId: string; motivo: string },
|
||||||
|
): Promise<FechamentoReabertoResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ReabrirFechamentoResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/reabrir`,
|
||||||
|
{
|
||||||
|
reaberto_por_id: input.reabertoPorId,
|
||||||
|
motivo: input.motivo.trim(),
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao reabrir fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async excluirFechamento(
|
||||||
|
fechamentoId: string,
|
||||||
|
input: { excluidoPorId: string; motivo: string },
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
await axios.delete(`${baseUrl}/fechamentos/${fechamentoId}`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
excluido_por_id: input.excluidoPorId,
|
||||||
|
motivo: input.motivo.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao excluir fechamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reprocessarAsana(fechamentoId: string): Promise<ReprocessarAsanaResponse["data"]> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<ReprocessarAsanaResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/reprocessar-asana`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao reprocessar tarefas do Asana.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async alterarCompetenciaTarefa(
|
||||||
|
fechamentoId: string,
|
||||||
|
tarefaId: string,
|
||||||
|
input: { competenciaDestinoId: string; alteradoPorId: string; motivo?: string },
|
||||||
|
): Promise<FechamentoTarefaItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<PatchTarefaResponse>(
|
||||||
|
`${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}/alterar-competencia`,
|
||||||
|
{
|
||||||
|
competencia_destino_id: input.competenciaDestinoId,
|
||||||
|
alterado_por_id: input.alteradoPorId,
|
||||||
|
...(input.motivo?.trim() ? { motivo: input.motivo.trim() } : {}),
|
||||||
|
},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao alterar competência da tarefa.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async alterarCompetenciaTarefasEmLote(
|
||||||
|
fechamentoId: string,
|
||||||
|
input: { tarefaIds: string[]; competenciaDestinoId: string; alteradoPorId: string; motivo?: string },
|
||||||
|
): Promise<{ alteradas: number; tarefas: FechamentoTarefaItem[] }> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<{
|
||||||
|
data: { alteradas: number; tarefas: FechamentoTarefaItem[] };
|
||||||
|
}>(`${baseUrl}/fechamentos/${fechamentoId}/tarefas/alterar-competencia`, {
|
||||||
|
tarefa_ids: input.tarefaIds,
|
||||||
|
competencia_destino_id: input.competenciaDestinoId,
|
||||||
|
alterado_por_id: input.alteradoPorId,
|
||||||
|
...(input.motivo?.trim() ? { motivo: input.motivo.trim() } : {}),
|
||||||
|
}, { headers });
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao alterar competência das tarefas.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoFechamentosService = new FechamentoFechamentosService();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user