Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff508f471 | |||
| 521ca9743f | |||
| 8a77c0dd31 | |||
| 34ae317fed | |||
| 422107331b | |||
| fc5fb3cc15 | |||
| 0b5c7350c5 | |||
| 6acfee2c76 | |||
| b4622c9166 | |||
| 517c9751bc | |||
| 343aae602f | |||
| 1c1e62c7db | |||
| 7859230468 | |||
| dee9ecfbca | |||
| d570029890 | |||
| b19e2963c4 | |||
| f2cc21f282 | |||
| 4c28862c5c | |||
| 6da067c641 |
+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
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
"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.36.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",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
+11
-16
@@ -4,12 +4,12 @@ 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();
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ const App = () => (
|
|||||||
defaultTheme="light"
|
defaultTheme="light"
|
||||||
themes={["light", "dark"]}
|
themes={["light", "dark"]}
|
||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
storageKey="hgtx-codex-theme"
|
storageKey="hgtx-commander-theme"
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -28,18 +28,15 @@ const App = () => (
|
|||||||
<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>
|
||||||
@@ -49,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -30,9 +51,9 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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>
|
||||||
|
|||||||
+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}</>;
|
||||||
|
}
|
||||||
@@ -96,9 +96,9 @@ interface ChatMessage {
|
|||||||
tipo_resposta?: "chat" | "agente" | null;
|
tipo_resposta?: "chat" | "agente" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LS_READER_FONT = "codex-parecer-reader-font-step";
|
const LS_READER_FONT = "commander-parecer-reader-font-step";
|
||||||
const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width";
|
const LS_READER_WIDTH = "commander-parecer-reader-comfortable-width";
|
||||||
const LS_MAIN_COMFORTABLE = "codex-parecer-main-comfortable-width";
|
const LS_MAIN_COMFORTABLE = "commander-parecer-main-comfortable-width";
|
||||||
|
|
||||||
/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */
|
/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */
|
||||||
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
|
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
|
||||||
@@ -588,7 +588,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
toast.success(res.message);
|
toast.success(res.message);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
navigate("/codex/parecer-juridico");
|
navigate("/commander/parecer-juridico");
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
||||||
@@ -672,7 +672,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
if (!id?.trim()) {
|
if (!id?.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background p-6">
|
<div className="flex flex-col h-full bg-background p-6">
|
||||||
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2">
|
<Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -697,7 +697,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
if (error && !parecer && !hasPreview) {
|
if (error && !parecer && !hasPreview) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background p-6">
|
<div className="flex flex-col h-full bg-background p-6">
|
||||||
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2">
|
<Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -721,7 +721,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||||
<header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border">
|
<header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border">
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function ParecerJuridicoFormView() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
navigate(`/codex/parecer-juridico/${data.id}`, {
|
navigate(`/commander/parecer-juridico/${data.id}`, {
|
||||||
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -136,11 +136,11 @@ export function ParecerJuridicoFormView() {
|
|||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
|
<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 bg-background pb-16 md:pb-0">
|
<div className="flex flex-col h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
|
||||||
<div className="p-3 md:p-6 border-b border-border">
|
<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 flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
<Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||||
@@ -151,8 +151,8 @@ export function ParecerJuridicoFormView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
<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">
|
<div className="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
||||||
<span>Título do parecer</span>
|
<span>Título do parecer</span>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const ParecerJuridicoView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditar = (item: ParecerListItem) => {
|
const handleEditar = (item: ParecerListItem) => {
|
||||||
navigate(`/codex/parecer-juridico/${item.id}`);
|
navigate(`/commander/parecer-juridico/${item.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +117,7 @@ export const ParecerJuridicoView = () => {
|
|||||||
Parecer Jurídico
|
Parecer Jurídico
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<Button onClick={() => navigate("/codex/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
<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" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="hidden sm:inline">Novo Parecer</span>
|
<span className="hidden sm:inline">Novo Parecer</span>
|
||||||
<span className="sm:hidden">Novo</span>
|
<span className="sm:hidden">Novo</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { ShimmerButton } from "@/components/ui/shimmer-button";
|
|
||||||
import { SparklesText } from "@/components/ui/sparkles-text";
|
|
||||||
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
||||||
import { BorderBeam } from "@/components/ui/border-beam";
|
import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
|
||||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
|
||||||
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePrompts } from "@/contexts/PromptsContext";
|
import { usePrompts } from "@/contexts/PromptsContext";
|
||||||
@@ -85,7 +82,7 @@ export function PromptsFormView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && !prompt && prompts.length > 0) {
|
if (id && !prompt && prompts.length > 0) {
|
||||||
navigate("/codex/prompts", { replace: true });
|
navigate("/commander/prompts", { replace: true });
|
||||||
}
|
}
|
||||||
}, [id, prompt, prompts.length, navigate]);
|
}, [id, prompt, prompts.length, navigate]);
|
||||||
|
|
||||||
@@ -143,7 +140,7 @@ export function PromptsFormView() {
|
|||||||
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
|
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
|
||||||
toast.success("Prompt criado.");
|
toast.success("Prompt criado.");
|
||||||
}
|
}
|
||||||
navigate("/codex/prompts");
|
navigate("/commander/prompts");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -224,7 +221,7 @@ export function PromptsFormView() {
|
|||||||
/>
|
/>
|
||||||
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
|
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}>
|
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,27 +274,11 @@ export function PromptsFormView() {
|
|||||||
title="Gerando em andamento"
|
title="Gerando em andamento"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ShimmerButton
|
<PromptAssistantButton
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAssistantOpen(true)}
|
onClick={() => setIsAssistantOpen(true)}
|
||||||
disabled={isAssistantWorkingInBackground}
|
disabled={isAssistantWorkingInBackground}
|
||||||
background="hsl(var(--card))"
|
loading={isAssistantWorkingInBackground}
|
||||||
shimmerColor="hsl(var(--foreground) / 0.14)"
|
/>
|
||||||
className={cn(
|
|
||||||
"gap-2 px-5 py-2.5 text-sm font-medium text-card-foreground border-border shadow-md hover:shadow-lg transition-shadow",
|
|
||||||
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAssistantWorkingInBackground ? (
|
|
||||||
<span className="text-sm font-medium text-card-foreground">
|
|
||||||
Gerando em segundo plano...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<SparklesText sparklesCount={5} className="text-sm font-medium text-card-foreground">
|
|
||||||
✨ Assistente de Criação de Prompt
|
|
||||||
</SparklesText>
|
|
||||||
)}
|
|
||||||
</ShimmerButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -327,21 +308,10 @@ export function PromptsFormView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
|
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-8 gap-6 overflow-hidden rounded-xl border-border/80 bg-gradient-to-b from-background to-muted/20">
|
<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">
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none">
|
<DialogHeader className="space-y-4">
|
||||||
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
|
|
||||||
</div>
|
|
||||||
<DialogHeader className="space-y-4 relative z-10">
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<PromptAssistantButton asBadge />
|
||||||
className={cn(
|
|
||||||
"inline-flex w-fit rounded-full border border-black/5 bg-neutral-100 text-base transition-all ease-in hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-2 transition ease-out hover:text-neutral-600 hover:duration-300 hover:dark:text-neutral-400">
|
|
||||||
<span>✨ Assistente de Criação de Prompt</span>
|
|
||||||
</AnimatedShinyText>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
||||||
<DialogDescription className="text-base text-muted-foreground">
|
<DialogDescription className="text-base text-muted-foreground">
|
||||||
@@ -400,7 +370,7 @@ export function PromptsFormView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-3 pt-4 relative z-10">
|
<DialogFooter className="gap-3 pt-4 relative z-10">
|
||||||
<Button variant="outline" className="hover:bg-slate-100 hover:text-slate-800 dark:hover:bg-slate-800" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
|
<Button variant="outline" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
|
||||||
Fechar
|
Fechar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const PromptsView = () => {
|
|||||||
const openEditFromDetails = () => {
|
const openEditFromDetails = () => {
|
||||||
if (!selectedPrompt) return;
|
if (!selectedPrompt) return;
|
||||||
setIsDetailsDialogOpen(false);
|
setIsDetailsDialogOpen(false);
|
||||||
navigate(`/codex/prompts/${selectedPrompt.id}`);
|
navigate(`/commander/prompts/${selectedPrompt.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteDialog = (prompt: Prompt) => {
|
const openDeleteDialog = (prompt: Prompt) => {
|
||||||
@@ -168,7 +168,7 @@ export const PromptsView = () => {
|
|||||||
Prompts
|
Prompts
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
<Button onClick={() => navigate("/codex/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
<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" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="hidden sm:inline">Novo prompt</span>
|
<span className="hidden sm:inline">Novo prompt</span>
|
||||||
<span className="sm:hidden">Novo</span>
|
<span className="sm:hidden">Novo</span>
|
||||||
@@ -231,7 +231,7 @@ export const PromptsView = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||||
<Button onClick={() => navigate("/codex/prompts/novo")}>
|
<Button onClick={() => navigate("/commander/prompts/novo")}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Novo prompt
|
Novo prompt
|
||||||
</Button>
|
</Button>
|
||||||
@@ -287,7 +287,7 @@ export const PromptsView = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
|
onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
|
||||||
title="Editar"
|
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"
|
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { type ComponentPropsWithoutRef } from "react"
|
import { type ComponentPropsWithoutRef, type CSSProperties } from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
|
||||||
speed?: number
|
speed?: number
|
||||||
colorFrom?: string
|
colorFrom?: string
|
||||||
colorTo?: string
|
colorTo?: string
|
||||||
@@ -12,23 +12,24 @@ export function AnimatedGradientText({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
speed = 1,
|
speed = 1,
|
||||||
colorFrom = "#ffaa40",
|
colorFrom = "#0055a4",
|
||||||
colorTo = "#9c40ff",
|
colorTo = "#0d8ecf",
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: AnimatedGradientTextProps) {
|
}: AnimatedGradientTextProps) {
|
||||||
|
const bgSize = `${speed * 300}%`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
className={cn("animated-gradient-text inline-block font-semibold", className)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--bg-size": `${speed * 300}%`,
|
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
|
||||||
"--color-from": colorFrom,
|
backgroundSize: `${bgSize} 100%`,
|
||||||
"--color-to": colorTo,
|
animation: "gradient-text-shift 12s ease-in-out infinite",
|
||||||
} as React.CSSProperties
|
...style,
|
||||||
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
className={cn(
|
|
||||||
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,54 +1,26 @@
|
|||||||
import { motion, MotionStyle, Transition } from "motion/react"
|
import type { CSSProperties } from "react";
|
||||||
|
import { motion, type MotionStyle, type Transition } from "motion/react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface BorderBeamProps {
|
interface BorderBeamProps {
|
||||||
/**
|
size?: number;
|
||||||
* The size of the border beam.
|
duration?: number;
|
||||||
*/
|
delay?: number;
|
||||||
size?: number
|
colorFrom?: string;
|
||||||
/**
|
colorTo?: string;
|
||||||
* The duration of the border beam.
|
transition?: Transition;
|
||||||
*/
|
className?: string;
|
||||||
duration?: number
|
style?: CSSProperties;
|
||||||
/**
|
reverse?: boolean;
|
||||||
* The delay of the border beam.
|
initialOffset?: number;
|
||||||
*/
|
borderWidth?: number;
|
||||||
delay?: number
|
|
||||||
/**
|
|
||||||
* The color of the border beam from.
|
|
||||||
*/
|
|
||||||
colorFrom?: string
|
|
||||||
/**
|
|
||||||
* The color of the border beam to.
|
|
||||||
*/
|
|
||||||
colorTo?: string
|
|
||||||
/**
|
|
||||||
* The motion transition of the border beam.
|
|
||||||
*/
|
|
||||||
transition?: Transition
|
|
||||||
/**
|
|
||||||
* The class name of the border beam.
|
|
||||||
*/
|
|
||||||
className?: string
|
|
||||||
/**
|
|
||||||
* The style of the border beam.
|
|
||||||
*/
|
|
||||||
style?: React.CSSProperties
|
|
||||||
/**
|
|
||||||
* Whether to reverse the animation direction.
|
|
||||||
*/
|
|
||||||
reverse?: boolean
|
|
||||||
/**
|
|
||||||
* The initial offset position (0-100).
|
|
||||||
*/
|
|
||||||
initialOffset?: number
|
|
||||||
/**
|
|
||||||
* The border width of the beam.
|
|
||||||
*/
|
|
||||||
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 = ({
|
export const BorderBeam = ({
|
||||||
className,
|
className,
|
||||||
size = 50,
|
size = 50,
|
||||||
@@ -62,30 +34,33 @@ export const BorderBeam = ({
|
|||||||
initialOffset = 0,
|
initialOffset = 0,
|
||||||
borderWidth = 1,
|
borderWidth = 1,
|
||||||
}: BorderBeamProps) => {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent mask-[linear-gradient(transparent,transparent),linear-gradient(#000,#000)] mask-intersect [mask-clip:padding-box,border-box]"
|
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||||
style={
|
style={maskWrapperStyle}
|
||||||
{
|
|
||||||
"--border-beam-width": `${borderWidth}px`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn("absolute aspect-square", className)}
|
||||||
"absolute aspect-square",
|
style={{ ...beamGradient, ...style } as MotionStyle}
|
||||||
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
|
|
||||||
"--color-from": colorFrom,
|
|
||||||
"--color-to": colorTo,
|
|
||||||
...style,
|
|
||||||
} as MotionStyle
|
|
||||||
}
|
|
||||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||||
animate={{
|
animate={{
|
||||||
offsetDistance: reverse
|
offsetDistance: reverse
|
||||||
@@ -101,5 +76,5 @@ export const BorderBeam = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
+181
-71
@@ -6,62 +6,123 @@
|
|||||||
|
|
||||||
@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-1: oklch(66.2% 0.225 25.9);
|
||||||
--color-2: oklch(60.4% 0.26 302);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--color-3: oklch(69.6% 0.165 251);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
@@ -70,62 +131,85 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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%;
|
||||||
|
|
||||||
|
/* Tokens extras — Dark */
|
||||||
|
--bubble-bot: 216 36% 14%;
|
||||||
|
--nav-active: 210 45% 16%;
|
||||||
|
--input-bg: 216 40% 10%;
|
||||||
|
--placeholder: 212 10% 31%;
|
||||||
|
--btn-secondary-border: 216 16% 30%;
|
||||||
|
--border-subtle: 216 22% 20%;
|
||||||
|
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.55);
|
||||||
|
--focus-ring: rgba(51, 136, 255, 0.35);
|
||||||
|
|
||||||
|
/* Gradientes — Dark */
|
||||||
|
--gradient-cyber: linear-gradient(135deg, hsl(210 90% 52%) 0%, hsl(210 100% 40%) 100%);
|
||||||
|
--gradient-subtle: linear-gradient(180deg, hsl(216 42% 11%) 0%, hsl(216 48% 8%) 100%);
|
||||||
|
--glow-cyan: 0 0 40px hsl(210 90% 52% / 0.35);
|
||||||
|
--glow-blue: 0 0 30px hsl(210 100% 40% / 0.28);
|
||||||
|
|
||||||
/* Custom gradients - Dark */
|
|
||||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 50%) 0%, hsl(210 100% 50%) 100%);
|
|
||||||
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%);
|
|
||||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3);
|
|
||||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25);
|
|
||||||
--color-1: oklch(66.2% 0.225 25.9);
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
--color-2: oklch(60.4% 0.26 302);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--color-3: oklch(69.6% 0.165 251);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--color-4: oklch(80.2% 0.134 225);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--color-5: oklch(90.7% 0.231 133);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme {
|
.theme {
|
||||||
--animate-shiny-text: shiny-text 8s infinite;
|
--animate-shiny-text: shiny-text 8s infinite;
|
||||||
--animate-gradient: gradient 8s linear infinite;
|
--animate-gradient: gradient 8s linear infinite;
|
||||||
@@ -144,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;
|
||||||
@@ -179,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 {
|
||||||
@@ -246,6 +346,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-text-shift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@keyframes shiny-text {
|
@keyframes shiny-text {
|
||||||
0%, 90%, 100% {
|
0%, 90%, 100% {
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -674,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"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,89 +0,0 @@
|
|||||||
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 { 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 { PromptsProvider } from "@/contexts/PromptsContext";
|
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate, Outlet } from "react-router-dom";
|
|
||||||
import React from "react";
|
|
||||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
|
||||||
|
|
||||||
type TabType = "chat" | "images" | "transcription" | "generation" | "prompts" | "parecerJuridico" | "areas";
|
|
||||||
|
|
||||||
const PATH_TO_TAB: Record<string, TabType> = {
|
|
||||||
"/codex/bate-papo": "chat",
|
|
||||||
"/codex/imagens": "images",
|
|
||||||
"/codex/transcricao-audio": "transcription",
|
|
||||||
"/codex/geracao-audio": "generation",
|
|
||||||
"/codex/parecer-juridico": "parecerJuridico",
|
|
||||||
"/codex/areas": "areas",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getActiveTabFromPath(pathname: string): TabType {
|
|
||||||
if (pathname.includes("/prompts")) return "prompts";
|
|
||||||
if (pathname.includes("/parecer-juridico")) return "parecerJuridico";
|
|
||||||
const tab = PATH_TO_TAB[pathname];
|
|
||||||
if (tab) return tab;
|
|
||||||
return "chat";
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAB_TO_PATH: Record<TabType, string> = {
|
|
||||||
chat: "/codex/bate-papo",
|
|
||||||
images: "/codex/imagens",
|
|
||||||
transcription: "/codex/transcricao-audio",
|
|
||||||
generation: "/codex/geracao-audio",
|
|
||||||
prompts: "/codex/prompts",
|
|
||||||
parecerJuridico: "/codex/parecer-juridico",
|
|
||||||
areas: "/codex/areas",
|
|
||||||
};
|
|
||||||
|
|
||||||
function CodexLayout() {
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const activeTab = getActiveTabFromPath(location.pathname);
|
|
||||||
|
|
||||||
const onTabChange = (tab: TabType) => {
|
|
||||||
navigate(TAB_TO_PATH[tab]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout activeTab={activeTab} onTabChange={onTabChange}>
|
|
||||||
<Outlet />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Index = () => {
|
|
||||||
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" element={<PromptsProvider><CodexLayout /></PromptsProvider>}>
|
|
||||||
<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="parecer-juridico" element={<ParecerJuridicoView />} />
|
|
||||||
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
|
|
||||||
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
|
|
||||||
<Route path="prompts" element={<PromptsView />} />
|
|
||||||
<Route path="prompts/novo" element={<PromptsFormView />} />
|
|
||||||
<Route path="prompts/:id" element={<PromptsFormView />} />
|
|
||||||
<Route path="areas" element={<AreasView />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Index;
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
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. */
|
||||||
|
export const LONG_RUNNING_REQUEST_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
private readonly axiosInstance: AxiosInstance;
|
private readonly axiosInstance: AxiosInstance;
|
||||||
private readonly apiKey: string;
|
private readonly apiKey: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
import { apiService } from './api';
|
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||||
|
|
||||||
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
|
||||||
|
|
||||||
@@ -135,7 +135,8 @@ class AudioGenerationService {
|
|||||||
try {
|
try {
|
||||||
const response = await apiService.post<AudioGenerationResponse>(
|
const response = await apiService.post<AudioGenerationResponse>(
|
||||||
this.AUDIO_GENERATION_ENDPOINT,
|
this.AUDIO_GENERATION_ENDPOINT,
|
||||||
{ estabelecimento_id: estabId, user_email: email, message, voice }
|
{ estabelecimento_id: estabId, user_email: email, message, voice },
|
||||||
|
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||||
|
|
||||||
|
function commanderOrigin(): string {
|
||||||
|
const base = resolveCommanderBaseUrl();
|
||||||
|
return base.replace(/\/api\/?$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve URL absoluta da logo do parceiro.
|
||||||
|
* Aceita URL absoluta, protocolo relativo (//), path absoluto (/...) ou path relativo (ex.: pasta/arquivo).
|
||||||
|
*/
|
||||||
|
export function resolveParceiroFotoUrl(logoUrl: string | null | undefined): string | null {
|
||||||
|
if (logoUrl == null) return null;
|
||||||
|
const t = logoUrl.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
if (/^https?:\/\//i.test(t)) return t;
|
||||||
|
if (t.startsWith("//")) {
|
||||||
|
return typeof window !== "undefined" && window.location.protocol === "http:" ? `http:${t}` : `https:${t}`;
|
||||||
|
}
|
||||||
|
const origin = commanderOrigin();
|
||||||
|
const withLeadingSlash = t.startsWith("/") ? t : `/${t}`;
|
||||||
|
return `${origin}${withLeadingSlash}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type ParceiroTipoPessoa = "fisica" | "juridica";
|
||||||
|
export type ParceiroStatusFiltro = "all" | "true" | "false";
|
||||||
|
|
||||||
|
export type ParceiroItem = {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
codinome: string | null;
|
||||||
|
tipoPessoa: ParceiroTipoPessoa;
|
||||||
|
cpf: string | null;
|
||||||
|
cnpj: string | null;
|
||||||
|
email: string;
|
||||||
|
whatsapp: string | null;
|
||||||
|
asanaId: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
observacoes: string | null;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
fator?: number | null;
|
||||||
|
estaAtivo: boolean;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetaResponse = {
|
||||||
|
total: number;
|
||||||
|
paginaAtual: number;
|
||||||
|
totalPaginas: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarParceirosResponse = {
|
||||||
|
data: ParceiroItem[];
|
||||||
|
meta: MetaResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarParceirosParams = {
|
||||||
|
busca?: string;
|
||||||
|
estaAtivo?: ParceiroStatusFiltro;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SalvarParceiroPayload = {
|
||||||
|
nome: string;
|
||||||
|
codinome?: string | null;
|
||||||
|
tipoPessoa: ParceiroTipoPessoa;
|
||||||
|
cpf?: string | null;
|
||||||
|
cnpj?: string | null;
|
||||||
|
email: string;
|
||||||
|
whatsapp?: string | null;
|
||||||
|
asanaId?: string | null;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
observacoes?: string | null;
|
||||||
|
pontuacaoMeta: number;
|
||||||
|
fator?: number | null;
|
||||||
|
estaAtivo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SalvarParceiroResponse = {
|
||||||
|
data: ParceiroItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoParceirosService {
|
||||||
|
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 listarParceiros(params: ListarParceirosParams): Promise<ListarParceirosResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarParceirosResponse>(`${baseUrl}/parceiros`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
busca: params.busca || undefined,
|
||||||
|
estaAtivo: params.estaAtivo ?? "all",
|
||||||
|
page: params.page,
|
||||||
|
perPage: params.perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar parceiros.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listarParceirosAtivos(): Promise<ParceiroItem[]> {
|
||||||
|
const response = await this.listarParceiros({
|
||||||
|
estaAtivo: "true",
|
||||||
|
page: 1,
|
||||||
|
perPage: 100,
|
||||||
|
});
|
||||||
|
return response.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async criarParceiro(payload: SalvarParceiroPayload): Promise<ParceiroItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<SalvarParceiroResponse>(`${baseUrl}/parceiros`, payload, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao criar parceiro.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editarParceiro(id: string, payload: Partial<SalvarParceiroPayload>): Promise<ParceiroItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.patch<SalvarParceiroResponse>(`${baseUrl}/parceiros/${id}`, payload, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao editar parceiro.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleAtivoParceiro(id: string): Promise<ParceiroItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.patch<SalvarParceiroResponse>(
|
||||||
|
`${baseUrl}/parceiros/${id}/toggle-ativo`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao atualizar status do parceiro.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoParceirosService = new FechamentoParceirosService();
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
|
||||||
|
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||||
|
|
||||||
|
export type UnidadeLookupRow = {
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
estabelecimentoId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheByCodigo = new Map<string, UnidadeLookupRow>();
|
||||||
|
|
||||||
|
type UnidadesListResponse = {
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
estabelecimentoId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildLookupHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await GlobalFunctions.getToken();
|
||||||
|
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(apiKey ? { apikey: apiKey } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCommanderUnidadeIdCache(): void {
|
||||||
|
cacheByCodigo.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Código de estabelecimento vindo do Transfer (host), ou string vazia. */
|
||||||
|
export function getEstabelecimentoCodigoFromTransfer(): string {
|
||||||
|
const raw = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||||
|
const codigo =
|
||||||
|
typeof raw === "string" ? raw.trim() : raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
|
||||||
|
return codigo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consulta `GET /unidades?estabelecimentoId=…` sem lançar; retorna `null` se não houver linha.
|
||||||
|
* Resultado é cacheado por código.
|
||||||
|
*/
|
||||||
|
export async function lookupUnidadeByEstabelecimento(codigo: string): Promise<UnidadeLookupRow | null> {
|
||||||
|
const key = codigo.trim();
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = cacheByCodigo.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildLookupHeaders();
|
||||||
|
const response = await axios.get<UnidadesListResponse>(`${baseUrl}/unidades`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
estabelecimentoId: key,
|
||||||
|
page: 1,
|
||||||
|
perPage: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const row =
|
||||||
|
response.data.data?.find((u) => u.estabelecimentoId === key) ?? response.data.data?.[0];
|
||||||
|
if (!row?.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped: UnidadeLookupRow = {
|
||||||
|
id: row.id,
|
||||||
|
nome: row.nome,
|
||||||
|
estabelecimentoId: row.estabelecimentoId,
|
||||||
|
};
|
||||||
|
cacheByCodigo.set(key, mapped);
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve o UUID da unidade no Commander a partir do código de estabelecimento
|
||||||
|
* disponibilizado pelo host (mesmo padrão de `audioGeneration.ts` / Codex).
|
||||||
|
* Lança se o código estiver ausente ou não existir unidade cadastrada.
|
||||||
|
*/
|
||||||
|
export async function resolveCommanderUnidadeId(): Promise<string> {
|
||||||
|
const codigo = getEstabelecimentoCodigoFromTransfer();
|
||||||
|
if (!codigo) {
|
||||||
|
throw new Error(
|
||||||
|
"EstabelecimentoCodigo não disponível (TransferArea). Não foi possível resolver a unidade para o Commander.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await lookupUnidadeByEstabelecimento(codigo);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(
|
||||||
|
`Nenhuma unidade encontrada no Commander para o estabelecimento "${codigo}". Cadastre a unidade ou verifique o código.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders } from "./commanderHttp";
|
||||||
|
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||||
|
import { clearCommanderUnidadeIdCache, type UnidadeLookupRow } from "./unidadeContext";
|
||||||
|
|
||||||
|
async function buildLookupHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await GlobalFunctions.getToken();
|
||||||
|
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(apiKey ? { apikey: apiKey } : {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsuarioEmailParaCommander(): string {
|
||||||
|
const email = GlobalFunctions.getUsuarioLogado().email?.trim();
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("E-mail do usuário logado não disponível para cadastro de unidade.");
|
||||||
|
}
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CriarUnidadeInput = {
|
||||||
|
nome: string;
|
||||||
|
estabelecimentoId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AtualizarUnidadeInput = {
|
||||||
|
nome?: string;
|
||||||
|
estabelecimentoId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnidadeResponse = { data: UnidadeLookupRow & { criadoEm: string } };
|
||||||
|
|
||||||
|
class FechamentoUnidadesService {
|
||||||
|
/**
|
||||||
|
* `POST /unidades` sem `X-Unidade-Id`. Exige `usuarioEmail` na query (validação admin no Commander).
|
||||||
|
*/
|
||||||
|
async createUnidade(input: CriarUnidadeInput): Promise<UnidadeLookupRow & { criadoEm: string }> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildLookupHeaders();
|
||||||
|
const usuarioEmail = getUsuarioEmailParaCommander();
|
||||||
|
const response = await axios.post<UnidadeResponse>(
|
||||||
|
`${baseUrl}/unidades`,
|
||||||
|
{
|
||||||
|
nome: input.nome.trim(),
|
||||||
|
estabelecimentoId: input.estabelecimentoId.trim(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
params: { usuarioEmail },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
clearCommanderUnidadeIdCache();
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUnidade(
|
||||||
|
unidadeId: string,
|
||||||
|
input: AtualizarUnidadeInput,
|
||||||
|
): Promise<UnidadeLookupRow & { criadoEm: string }> {
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const body: Record<string, string> = {};
|
||||||
|
if (input.nome !== undefined) body.nome = input.nome.trim();
|
||||||
|
if (input.estabelecimentoId !== undefined) body.estabelecimentoId = input.estabelecimentoId.trim();
|
||||||
|
|
||||||
|
const response = await axios.patch<UnidadeResponse>(`${baseUrl}/unidades/${unidadeId}`, body, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
clearCommanderUnidadeIdCache();
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoUnidadesService = new FechamentoUnidadesService();
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PresignParceiroLogoResponse = {
|
||||||
|
data: {
|
||||||
|
uploadUrl: string;
|
||||||
|
publicUrl: string;
|
||||||
|
objectKey: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresignParceiroLogoPayload = {
|
||||||
|
fileName: string;
|
||||||
|
contentType: "image/jpeg" | "image/png" | "image/webp";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PresignEmpresaLogoPayload = PresignParceiroLogoPayload;
|
||||||
|
|
||||||
|
class FechamentoUploadsService {
|
||||||
|
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 presignUploadParceiroLogo(payload: PresignParceiroLogoPayload) {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<PresignParceiroLogoResponse>(
|
||||||
|
`${baseUrl}/uploads/parceiros/logo/presign`,
|
||||||
|
payload,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao preparar upload da logo.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async presignUploadEmpresaLogo(payload: PresignEmpresaLogoPayload) {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<PresignParceiroLogoResponse>(
|
||||||
|
`${baseUrl}/uploads/configuracoes/empresa-logo/presign`,
|
||||||
|
payload,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao preparar upload da logo da empresa.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFileToSignedUrl(uploadUrl: string, file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
await axios.put(uploadUrl, file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
throw new Error(error.message || "Falha ao enviar arquivo para armazenamento.");
|
||||||
|
}
|
||||||
|
throw new Error("Falha ao enviar arquivo para armazenamento.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoUploadsService = new FechamentoUploadsService();
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
|
||||||
|
export type UsuarioPapel = "admin" | "parceiro" | "supervisor";
|
||||||
|
export type UsuarioStatusFiltro = "all" | "true" | "false";
|
||||||
|
|
||||||
|
export type UsuarioItem = {
|
||||||
|
id: string;
|
||||||
|
parceiroId: string | null;
|
||||||
|
nome: string | null;
|
||||||
|
email: string | null;
|
||||||
|
papel: UsuarioPapel;
|
||||||
|
estaAtivo: boolean;
|
||||||
|
criadoEm: string;
|
||||||
|
atualizadoEm: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetaResponse = {
|
||||||
|
total: number;
|
||||||
|
paginaAtual: number;
|
||||||
|
totalPaginas: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListarUsuariosResponse = {
|
||||||
|
data: UsuarioItem[];
|
||||||
|
meta: MetaResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiErrorShape = {
|
||||||
|
error?: {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListarUsuariosParams = {
|
||||||
|
busca?: string;
|
||||||
|
papel?: UsuarioPapel;
|
||||||
|
estaAtivo?: UsuarioStatusFiltro;
|
||||||
|
page: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SalvarUsuarioPayload = {
|
||||||
|
nome?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
papel: UsuarioPapel;
|
||||||
|
parceiroId?: string | null;
|
||||||
|
estaAtivo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SalvarUsuarioResponse = {
|
||||||
|
data: UsuarioItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FechamentoUsuariosService {
|
||||||
|
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 listarUsuarios(params: ListarUsuariosParams): Promise<ListarUsuariosResponse> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.get<ListarUsuariosResponse>(`${baseUrl}/usuarios`, {
|
||||||
|
headers,
|
||||||
|
params: {
|
||||||
|
busca: params.busca || undefined,
|
||||||
|
papel: params.papel || undefined,
|
||||||
|
estaAtivo: params.estaAtivo ?? "all",
|
||||||
|
page: params.page,
|
||||||
|
perPage: params.perPage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao listar usuários.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async criarUsuario(payload: SalvarUsuarioPayload): Promise<UsuarioItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.post<SalvarUsuarioResponse>(`${baseUrl}/usuarios`, payload, { headers });
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao criar usuário.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editarUsuario(id: string, payload: Partial<SalvarUsuarioPayload>): Promise<UsuarioItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.patch<SalvarUsuarioResponse>(`${baseUrl}/usuarios/${id}`, payload, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao editar usuário.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleAtivoUsuario(id: string): Promise<UsuarioItem> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
const response = await axios.patch<SalvarUsuarioResponse>(
|
||||||
|
`${baseUrl}/usuarios/${id}/toggle-ativo`,
|
||||||
|
{},
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao atualizar status do usuário.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async excluirUsuario(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await buildCommanderHeaders();
|
||||||
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
|
await axios.delete(`${baseUrl}/usuarios/${id}`, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
this.handleAxiosError(error, "Erro ao excluir usuário.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fechamentoUsuariosService = new FechamentoUsuariosService();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
import { apiService } from './api';
|
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||||
|
|
||||||
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
||||||
|
|
||||||
@@ -127,7 +127,8 @@ class ImageGenerationService {
|
|||||||
try {
|
try {
|
||||||
const response = await apiService.post<ImageGenerationResponse>(
|
const response = await apiService.post<ImageGenerationResponse>(
|
||||||
this.IMAGE_GENERATION_ENDPOINT,
|
this.IMAGE_GENERATION_ENDPOINT,
|
||||||
{ estabelecimento_id: estabId, user_email: email, description, size }
|
{ estabelecimento_id: estabId, user_email: email, description, size },
|
||||||
|
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.data.success) {
|
if (!response.data.success) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||||
import { apiService } from './api';
|
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||||
|
|
||||||
export interface TranscriptionResponse {
|
export interface TranscriptionResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -82,7 +82,8 @@ class TranscriptionService {
|
|||||||
try {
|
try {
|
||||||
const response = await apiService.postFormData<TranscriptionResponse>(
|
const response = await apiService.postFormData<TranscriptionResponse>(
|
||||||
this.TRANSCRIPTION_ENDPOINT,
|
this.TRANSCRIPTION_ENDPOINT,
|
||||||
formData
|
formData,
|
||||||
|
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
Vendored
+1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_API_BASE_URL_COMMANDER: string
|
||||||
readonly VITE_API_KEY: string
|
readonly VITE_API_KEY: string
|
||||||
readonly VITE_USER_EMAIL: string
|
readonly VITE_USER_EMAIL: string
|
||||||
readonly VITE_ESTABELECIMENTO_ID: string
|
readonly VITE_ESTABELECIMENTO_ID: string
|
||||||
|
|||||||
+43
-7
@@ -25,6 +25,9 @@ export default {
|
|||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
hover: "hsl(var(--primary-hover))",
|
||||||
|
pressed: "hsl(var(--primary-pressed))",
|
||||||
|
light: "hsl(var(--primary-light))",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
@@ -68,11 +71,33 @@ export default {
|
|||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
ring: "hsl(var(--sidebar-ring))",
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
},
|
},
|
||||||
|
"bubble-bot": "hsl(var(--bubble-bot))",
|
||||||
|
"nav-active": "hsl(var(--nav-active))",
|
||||||
|
"input-bg": "hsl(var(--input-bg))",
|
||||||
|
placeholder: "hsl(var(--placeholder))",
|
||||||
|
"btn-secondary-border": "hsl(var(--btn-secondary-border))",
|
||||||
|
"border-subtle": "hsl(var(--border-subtle))",
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
sm: "var(--radius-sm)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "var(--radius-md)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
lg: "var(--radius-lg)",
|
||||||
|
xl: "var(--radius-xl)",
|
||||||
|
"2xl": "20px",
|
||||||
|
pill: "var(--radius-pill)",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "var(--shadow-card)",
|
||||||
|
modal: "var(--shadow-modal)",
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
display: ["32px", { lineHeight: "1.25", fontWeight: "700" }],
|
||||||
|
h1: ["24px", { lineHeight: "1.25", fontWeight: "600" }],
|
||||||
|
h2: ["18px", { lineHeight: "1.5", fontWeight: "600" }],
|
||||||
|
h3: ["15px", { lineHeight: "1.5" }],
|
||||||
|
body: ["14px", { lineHeight: "1.5" }],
|
||||||
|
small: ["12px", { lineHeight: "1.5" }],
|
||||||
|
caption: ["11px", { lineHeight: "1.5" }],
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
@@ -84,15 +109,25 @@ export default {
|
|||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
"pulse-glow": {
|
"pulse-glow": {
|
||||||
"0%, 100%": {
|
"0%, 100%": {
|
||||||
boxShadow: "0 0 20px hsl(190 100% 50% / 0.3)",
|
boxShadow: "0 0 20px hsl(210 100% 33% / 0.3)",
|
||||||
transform: "scale(1)"
|
transform: "scale(1)"
|
||||||
},
|
},
|
||||||
"50%": {
|
"50%": {
|
||||||
boxShadow: "0 0 40px hsl(190 100% 50% / 0.5)",
|
boxShadow: "0 0 40px hsl(210 100% 33% / 0.5)",
|
||||||
transform: "scale(1.02)"
|
transform: "scale(1.02)"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"online-pulse": {
|
||||||
|
"0%, 100%": {
|
||||||
|
boxShadow: "0 0 0 0 hsl(134 61% 41% / 0.7)",
|
||||||
|
opacity: "1",
|
||||||
|
},
|
||||||
|
"50%": {
|
||||||
|
boxShadow: "0 0 0 4px hsl(134 61% 41% / 0)",
|
||||||
|
opacity: "0.85",
|
||||||
|
},
|
||||||
|
},
|
||||||
"slide-in-right": {
|
"slide-in-right": {
|
||||||
"0%": { transform: "translateX(100%)", opacity: "0" },
|
"0%": { transform: "translateX(100%)", opacity: "0" },
|
||||||
"100%": { transform: "translateX(0)", opacity: "1" },
|
"100%": { transform: "translateX(0)", opacity: "1" },
|
||||||
@@ -106,6 +141,7 @@ export default {
|
|||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
"pulse-glow": "pulse-glow 2s ease-in-out infinite",
|
"pulse-glow": "pulse-glow 2s ease-in-out infinite",
|
||||||
|
"online-pulse": "online-pulse 1.5s ease-in-out infinite",
|
||||||
"slide-in-right": "slide-in-right 0.3s ease-out",
|
"slide-in-right": "slide-in-right 0.3s ease-out",
|
||||||
"fade-in": "fade-in 0.3s ease-out",
|
"fade-in": "fade-in 0.3s ease-out",
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./vite.config.ts"],"version":"5.8.3"}
|
||||||
Reference in New Issue
Block a user