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_ESTABELECIMENTO_ID=1
|
||||
|
||||
# Instruções:
|
||||
# 1. Copie este arquivo para .env
|
||||
# 2. Substitua os valores acima com as configurações reais
|
||||
# 3. NUNCA commite o arquivo .env no git
|
||||
# API do módulo de fechamento (Commander)
|
||||
VITE_API_BASE_URL_COMMANDER=https://hgtx-opencommander-api.goutron.com.br/api
|
||||
|
||||
@@ -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>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HGTX Codex - AI Interface System</title>
|
||||
<meta name="description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||
<meta name="author" content="HGTX Codex" />
|
||||
<title>HGTX Commander</title>
|
||||
<meta name="description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
|
||||
<meta name="author" content="HGTX Commander" />
|
||||
|
||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
||||
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||
<meta property="og:title" content="HGTX Commander" />
|
||||
<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:image" content="/favicon.png" />
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^12.36.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Redirect from "./pages/Redirect";
|
||||
import { GlobalFunctions } from "./GlobalFunctions";
|
||||
import React from "react";
|
||||
import CommanderApp from "./modules/commander/App";
|
||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||
import IntelligenceScoreApp from "./modules/intelligence-score/App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -20,7 +20,7 @@ const App = () => (
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark"]}
|
||||
enableSystem={false}
|
||||
storageKey="hgtx-codex-theme"
|
||||
storageKey="hgtx-commander-theme"
|
||||
enableColorScheme
|
||||
>
|
||||
<TooltipProvider>
|
||||
@@ -28,18 +28,15 @@ const App = () => (
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/redir/:gsikey"
|
||||
element={<Redirect />}
|
||||
/>
|
||||
<Route 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-score/*" element={<IntelligenceScoreApp />} />
|
||||
|
||||
<Route path="/*" element={<Index />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/" element={<Navigate to="/commander" replace />} />
|
||||
<Route path="/404" element={<NotFound />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
@@ -49,12 +46,10 @@ const App = () => (
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
const HGTXLogin = () =>{
|
||||
|
||||
const HGTXLogin = () => {
|
||||
React.useEffect(() => {
|
||||
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">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ChatHeader = ({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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 { useToast } from "@/hooks/use-toast";
|
||||
import { chatService } from "@/services/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatInputProps {
|
||||
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 [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const [textareaHeight, setTextareaHeight] = useState(60);
|
||||
const [textareaHeight, setTextareaHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dragStartY = useRef<number>(0);
|
||||
const dragStartHeight = useRef<number>(0);
|
||||
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) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
@@ -31,8 +52,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaY = dragStartY.current - e.clientY; // Inverted: moving up = positive
|
||||
const newHeight = Math.min(400, Math.max(60, dragStartHeight.current + deltaY));
|
||||
const deltaY = dragStartY.current - e.clientY;
|
||||
const newHeight = Math.min(400, Math.max(DEFAULT_TEXTAREA_HEIGHT, dragStartHeight.current + deltaY));
|
||||
setTextareaHeight(newHeight);
|
||||
};
|
||||
|
||||
@@ -41,16 +62,30 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [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 = () => {
|
||||
if (message.trim() || attachedFiles.length > 0) {
|
||||
onSendMessage(message, attachedFiles);
|
||||
@@ -70,7 +105,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const newFiles = [...attachedFiles, ...files];
|
||||
|
||||
// Valida quantidade máxima de anexos (5)
|
||||
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||
toast({
|
||||
title: "Limite de anexos excedido",
|
||||
@@ -83,10 +117,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Valida tipos de arquivo e tamanho
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
// Valida tamanho (max 10MB por arquivo)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: "Arquivo muito grande",
|
||||
@@ -99,7 +131,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Valida formatos permitidos
|
||||
const allFiles = [...attachedFiles, ...validFiles];
|
||||
const validation = chatService.validateAttachments(allFiles);
|
||||
|
||||
@@ -125,65 +156,64 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const canSend = Boolean(message.trim() || attachedFiles.length > 0);
|
||||
const textareaStyleHeight = isMobile ? MOBILE_TEXTAREA_HEIGHT : textareaHeight;
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
{/* Attached Files Preview */}
|
||||
<div className="w-full border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-2 px-2 md:space-y-3 md:px-4">
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 md:gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
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" />
|
||||
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
||||
<span className="hidden md:inline text-xs text-muted-foreground">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 ml-1"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeFile(index)}
|
||||
aria-label={`Remover ${file.name}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Input and Buttons */}
|
||||
<div className="flex gap-1 md:gap-2 items-end">
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||
{/* Resize Handle - Hidden on mobile */}
|
||||
<div className="glass-effect overflow-hidden rounded-2xl border border-border/60 shadow-sm">
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
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 cima para aumentar"
|
||||
className={cn(
|
||||
"hidden h-5 w-full cursor-ns-resize items-center justify-center border-b border-border/40 transition-colors md:flex",
|
||||
isDragging ? "bg-primary/15" : "hover:bg-primary/8",
|
||||
)}
|
||||
title="Arraste para aumentar a altura"
|
||||
>
|
||||
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
|
||||
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
||||
<div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
|
||||
<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"
|
||||
style={{ height: textareaStyleHeight }}
|
||||
className="min-h-0 w-full resize-none border-0 bg-transparent p-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 md:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1 md:gap-2">
|
||||
<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"
|
||||
@@ -194,27 +224,40 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
||||
className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Anexar arquivo"
|
||||
aria-label="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
||||
<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
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
|
||||
disabled={!message.trim() && attachedFiles.length === 0}
|
||||
disabled={!canSend}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||
<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()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+256
-231
@@ -1,5 +1,18 @@
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -11,6 +24,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -33,6 +47,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -42,7 +57,19 @@ interface ChatSidebarProps {
|
||||
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 [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
@@ -56,49 +83,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Estado carregado do banco de dados via chatService
|
||||
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||
/** Pastas fechadas por padrão; estado preservado ao recarregar lista */
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Carrega chats e pastas do banco de dados quando o componente monta
|
||||
useEffect(() => {
|
||||
loadChatsAndFolders();
|
||||
|
||||
// Escuta evento customizado para recarregar quando chat é atualizado
|
||||
const handleChatUpdate = () => {
|
||||
loadChatsAndFolders();
|
||||
};
|
||||
|
||||
window.addEventListener('chatUpdated', handleChatUpdate);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('chatUpdated', handleChatUpdate);
|
||||
};
|
||||
window.addEventListener("chatUpdated", handleChatUpdate);
|
||||
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
|
||||
}, []);
|
||||
|
||||
const loadChatsAndFolders = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await chatService.getChatsAndFolders();
|
||||
|
||||
console.log('Dados carregados:', data);
|
||||
|
||||
setChats(data.chats);
|
||||
setFolders(data.folders);
|
||||
|
||||
// Expande todas as pastas por padrão
|
||||
setExpandedFolders(new Set(data.folders.map(f => f.id)));
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar chats e pastas:', error);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
|
||||
console.error("Erro ao carregar chats e pastas:", error);
|
||||
toast({
|
||||
title: "Erro ao carregar dados",
|
||||
description: error.message || "Não foi possível carregar chats e pastas.",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Define arrays vazios em caso de erro
|
||||
setChats([]);
|
||||
setFolders([]);
|
||||
} finally {
|
||||
@@ -107,48 +121,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (newFolderName.trim()) {
|
||||
if (!newFolderName.trim()) return;
|
||||
const name = newFolderName.trim();
|
||||
try {
|
||||
await chatService.createFolder(newFolderName);
|
||||
await chatService.createFolder(name);
|
||||
await loadChatsAndFolders();
|
||||
setNewFolderName("");
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta criada",
|
||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
||||
});
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível criar a pasta.";
|
||||
toast({ title: "Erro ao criar pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = async () => {
|
||||
if (deletingFolder) {
|
||||
if (!deletingFolder) return;
|
||||
try {
|
||||
await chatService.deleteFolder(deletingFolder.id);
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deletingFolder.id);
|
||||
return next;
|
||||
});
|
||||
await loadChatsAndFolders();
|
||||
setDeletingFolder(null);
|
||||
setIsDeleteFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta excluída",
|
||||
description: "A pasta foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao excluir pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir pasta",
|
||||
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
|
||||
toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,79 +166,52 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleRenameFolder = async () => {
|
||||
if (renamingFolder && renamedFolderName.trim()) {
|
||||
if (!renamingFolder || !renamedFolderName.trim()) return;
|
||||
try {
|
||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setRenamingFolder(null);
|
||||
setRenamedFolderName("");
|
||||
setIsRenameFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta renomeada",
|
||||
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
|
||||
});
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
|
||||
toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folderId)) {
|
||||
newExpanded.delete(folderId);
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||
try {
|
||||
await chatService.moveChatToFolder(chatId, folderId);
|
||||
await loadChatsAndFolders();
|
||||
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
toast({
|
||||
title: "Chat movido",
|
||||
description: `Movido para a pasta "${folder?.name}".`,
|
||||
});
|
||||
} 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 folder = folders.find((f) => f.id === folderId);
|
||||
toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
|
||||
toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChat = async () => {
|
||||
if (deletingChat) {
|
||||
if (!deletingChat) return;
|
||||
try {
|
||||
await chatService.deleteChat(deletingChat.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingChat(null);
|
||||
setIsDeleteChatOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Chat excluído",
|
||||
description: "A conversa foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: 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",
|
||||
});
|
||||
}
|
||||
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
|
||||
toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,223 +221,233 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleSelectChat = (chat: ChatRecord) => {
|
||||
if (onSelectChat) {
|
||||
onSelectChat(chat);
|
||||
}
|
||||
onSelectChat?.(chat);
|
||||
};
|
||||
|
||||
// Filtrar chats pela busca
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
||||
return titleMatch;
|
||||
});
|
||||
const filteredChats = chats.filter((chat) =>
|
||||
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Separar chats sem pasta (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((acc, folder) => {
|
||||
const chatsByFolder = folders.reduce(
|
||||
(acc, folder) => {
|
||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||
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) {
|
||||
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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Expandir conversas"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
||||
{/* Header with collapse button */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<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">
|
||||
{/* Cabeçalho */}
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pb-3 border-b border-border space-y-3">
|
||||
{/* Busca + ações */}
|
||||
<div className="min-w-0 shrink-0 space-y-2 border-b border-border px-3 py-3">
|
||||
<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
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Pesquisar conversas..."
|
||||
className="pl-9 bg-muted/50"
|
||||
className="h-9 bg-background/80 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2 cyber-glow" variant="default" onClick={onNewChat}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Chat
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button className="h-9 gap-1.5 text-sm" variant="default" onClick={onNewChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo chat
|
||||
</Button>
|
||||
|
||||
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full gap-2" size="sm">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Nova Pasta
|
||||
<Button variant="outline" className="h-9 gap-1.5 text-sm">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Pasta
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||
<DialogContent className="border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Nova Pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Organize suas conversas em pastas personalizadas.
|
||||
</DialogDescription>
|
||||
<DialogTitle>Nova pasta</DialogTitle>
|
||||
<DialogDescription>Organize suas conversas em pastas.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="folder-name">Nome da Pasta</Label>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="folder-name">Nome</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Ex: Projetos, Estudos..."
|
||||
placeholder="Ex: Projetos, Clientes..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||
Criar Pasta
|
||||
Criar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rename Folder Dialog */}
|
||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||
<DialogContent className="border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Renomear Pasta</DialogTitle>
|
||||
<DialogTitle>Renomear pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
||||
Novo nome para "{renamingFolder?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
||||
<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)}
|
||||
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
|
||||
Salvar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Folder Alert */}
|
||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||
<AlertDialogContent className="border-border bg-card">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
|
||||
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">
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteFolder}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{/* Lista */}
|
||||
<ScrollArea className="min-h-0 w-full min-w-0 flex-1 overflow-hidden">
|
||||
<div className="box-border w-full max-w-full min-w-0 space-y-1 overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Folders */}
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{visibleFolders.length > 0 && (
|
||||
<section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
|
||||
{visibleFolders.map((folder) => {
|
||||
const folderChats = chatsByFolder[folder.id] ?? [];
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
|
||||
return (
|
||||
<div key={folder.id} className="min-w-0">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent/50"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<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">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
!isExpanded && "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary/90" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chatsByFolder[folder.id]?.length || 0}
|
||||
<span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||
{folderChats.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => openRenameFolder(folder)}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
Renomear Pasta
|
||||
<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"
|
||||
className="gap-2 text-destructive focus:text-destructive"
|
||||
onClick={() => openDeleteFolder(folder)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Excluir Pasta
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Excluir pasta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||
<div className="ml-6 space-y-1">
|
||||
{chatsByFolder[folder.id].map((chat) => (
|
||||
{isExpanded && (
|
||||
<div className="min-w-0 space-y-0.5 py-0.5 pl-5">
|
||||
{folderChats.length > 0 ? (
|
||||
folderChats.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
@@ -471,19 +456,29 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
nested
|
||||
/>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Chats without folder */}
|
||||
{chatsWithoutFolder.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Sem Pasta
|
||||
</div>
|
||||
<section
|
||||
className={cn(
|
||||
"w-full min-w-0 max-w-full overflow-hidden",
|
||||
visibleFolders.length > 0 && "mt-3 border-t border-border/40 pt-3"
|
||||
)}
|
||||
>
|
||||
<SectionLabel>Conversas</SectionLabel>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{chatsWithoutFolder.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
@@ -496,37 +491,48 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conversa ainda
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Clique em "Novo Chat" para começar
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
|
||||
<Inbox className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Nenhuma conversa</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Crie um novo chat para começar
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
chats.length > 0 &&
|
||||
filteredChats.length === 0 &&
|
||||
searchQuery.trim() && (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado para "{searchQuery}"
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Delete Chat Alert */}
|
||||
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||
<AlertDialogContent className="border-border bg-card">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
||||
"{deletingChat?.title}" será excluída permanentemente.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<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
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -543,6 +549,7 @@ interface ChatItemProps {
|
||||
folders: FolderRecord[];
|
||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||
onDelete: () => void;
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
const ChatItem = ({
|
||||
@@ -552,8 +559,8 @@ const ChatItem = ({
|
||||
folders,
|
||||
onMoveToFolder,
|
||||
onDelete,
|
||||
nested = false,
|
||||
}: ChatItemProps) => {
|
||||
// Formata timestamp relativo
|
||||
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
@@ -561,19 +568,34 @@ const ChatItem = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
|
||||
isSelected
|
||||
? "bg-sidebar-accent cyber-border"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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" />
|
||||
<div className="flex-1 min-w-0 max-w-[180px]">
|
||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"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}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
||||
<p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
|
||||
{timeAgo}
|
||||
</p>
|
||||
</div>
|
||||
@@ -583,29 +605,32 @@ const ChatItem = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
{/* Opções para mover para pastas */}
|
||||
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
{folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
|
||||
<>
|
||||
{folders
|
||||
.filter((f) => f.id !== chat.folder_id)
|
||||
.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
className="gap-2"
|
||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Mover para {folder.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -3,6 +3,13 @@ import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
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 { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -11,18 +18,6 @@ import { getModelId } from "@/config/models";
|
||||
// Texto padrão da personalidade (usado para novos chats)
|
||||
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 = () => {
|
||||
const { toast } = useToast();
|
||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||
@@ -31,16 +26,17 @@ export const ChatView = () => {
|
||||
const [currentChatId, setCurrentChatId] = useState("0");
|
||||
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: "GPT-4o",
|
||||
},
|
||||
]);
|
||||
const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
|
||||
|
||||
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
|
||||
// quando enviadas via handleSendMessage
|
||||
@@ -82,14 +78,7 @@ export const ChatView = () => {
|
||||
// Reseta a personalidade para o texto padrão
|
||||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
setMessages([createWelcomeMessage()]);
|
||||
|
||||
// Dispara evento para atualizar a sidebar
|
||||
window.dispatchEvent(new Event('chatUpdated'));
|
||||
@@ -136,14 +125,7 @@ export const ChatView = () => {
|
||||
});
|
||||
|
||||
// Inicia com mensagem padrão em caso de erro
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
setMessages([createWelcomeMessage()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -162,7 +144,7 @@ export const ChatView = () => {
|
||||
})),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -234,7 +216,7 @@ export const ChatView = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||
{/* 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
|
||||
isCollapsed={isChatSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||
@@ -256,7 +238,13 @@ export const ChatView = () => {
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||
{messages.map((message) => (
|
||||
{messages.map((message) =>
|
||||
isWelcomeMessage(message) ? (
|
||||
<ChatWelcome
|
||||
key={message.id}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
@@ -264,7 +252,8 @@ export const ChatView = () => {
|
||||
model={message.model}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-4 py-6 px-6 bg-muted/30">
|
||||
@@ -281,7 +270,12 @@ export const ChatView = () => {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<ChatInput onSendMessage={handleSendMessage} />
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
draftMessage={inputDraft?.text}
|
||||
draftTrigger={inputDraft?.at}
|
||||
onDraftApplied={() => setInputDraft(null)}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
|
||||
const LS_READER_FONT = "codex-parecer-reader-font-step";
|
||||
const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width";
|
||||
const LS_MAIN_COMFORTABLE = "codex-parecer-main-comfortable-width";
|
||||
const LS_READER_FONT = "commander-parecer-reader-font-step";
|
||||
const LS_READER_WIDTH = "commander-parecer-reader-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). */
|
||||
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
|
||||
@@ -588,7 +588,7 @@ export function ParecerJuridicoDetailView() {
|
||||
.then((res) => {
|
||||
toast.success(res.message);
|
||||
setDeleteOpen(false);
|
||||
navigate("/codex/parecer-juridico");
|
||||
navigate("/commander/parecer-juridico");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
||||
@@ -672,7 +672,7 @@ export function ParecerJuridicoDetailView() {
|
||||
if (!id?.trim()) {
|
||||
return (
|
||||
<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" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -697,7 +697,7 @@ export function ParecerJuridicoDetailView() {
|
||||
if (error && !parecer && !hasPreview) {
|
||||
return (
|
||||
<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" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -721,7 +721,7 @@ export function ParecerJuridicoDetailView() {
|
||||
<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">
|
||||
<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" />
|
||||
</Button>
|
||||
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ParecerJuridicoFormView() {
|
||||
.then((data) => {
|
||||
setIsGenerating(false);
|
||||
playSuccessSound();
|
||||
navigate(`/codex/parecer-juridico/${data.id}`, {
|
||||
navigate(`/commander/parecer-juridico/${data.id}`, {
|
||||
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
||||
});
|
||||
})
|
||||
@@ -136,11 +136,11 @@ export function ParecerJuridicoFormView() {
|
||||
{isGenerating && (
|
||||
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
|
||||
)}
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<div className="p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
|
||||
<div className="shrink-0 p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/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" />
|
||||
</Button>
|
||||
<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 className="flex-1 overflow-auto p-3 md:p-6">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-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 pb-10">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
||||
<span>Título do parecer</span>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ParecerJuridicoView = () => {
|
||||
};
|
||||
|
||||
const handleEditar = (item: ParecerListItem) => {
|
||||
navigate(`/codex/parecer-juridico/${item.id}`);
|
||||
navigate(`/commander/parecer-juridico/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -117,7 +117,7 @@ export const ParecerJuridicoView = () => {
|
||||
Parecer Jurídico
|
||||
</h1>
|
||||
<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" />
|
||||
<span className="hidden sm:inline">Novo Parecer</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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { BorderBeam } from "@/components/ui/border-beam";
|
||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
||||
import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
|
||||
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
@@ -85,7 +82,7 @@ export function PromptsFormView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (id && !prompt && prompts.length > 0) {
|
||||
navigate("/codex/prompts", { replace: true });
|
||||
navigate("/commander/prompts", { replace: true });
|
||||
}
|
||||
}, [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 ?? "" }]);
|
||||
toast.success("Prompt criado.");
|
||||
}
|
||||
navigate("/codex/prompts");
|
||||
navigate("/commander/prompts");
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
||||
toast.error(message);
|
||||
@@ -224,7 +221,7 @@ export function PromptsFormView() {
|
||||
/>
|
||||
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}>
|
||||
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -277,27 +274,11 @@ export function PromptsFormView() {
|
||||
title="Gerando em andamento"
|
||||
/>
|
||||
)}
|
||||
<ShimmerButton
|
||||
type="button"
|
||||
<PromptAssistantButton
|
||||
onClick={() => setIsAssistantOpen(true)}
|
||||
disabled={isAssistantWorkingInBackground}
|
||||
background="hsl(var(--card))"
|
||||
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>
|
||||
loading={isAssistantWorkingInBackground}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -327,21 +308,10 @@ export function PromptsFormView() {
|
||||
)}
|
||||
|
||||
<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">
|
||||
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none">
|
||||
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
|
||||
</div>
|
||||
<DialogHeader className="space-y-4 relative z-10">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col gap-6 overflow-hidden rounded-xl border border-border/80 bg-card p-8 shadow-modal">
|
||||
<DialogHeader className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
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>
|
||||
<PromptAssistantButton asBadge />
|
||||
</div>
|
||||
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
||||
<DialogDescription className="text-base text-muted-foreground">
|
||||
@@ -400,7 +370,7 @@ export function PromptsFormView() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -127,7 +127,7 @@ export const PromptsView = () => {
|
||||
const openEditFromDetails = () => {
|
||||
if (!selectedPrompt) return;
|
||||
setIsDetailsDialogOpen(false);
|
||||
navigate(`/codex/prompts/${selectedPrompt.id}`);
|
||||
navigate(`/commander/prompts/${selectedPrompt.id}`);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (prompt: Prompt) => {
|
||||
@@ -168,7 +168,7 @@ export const PromptsView = () => {
|
||||
Prompts
|
||||
</h1>
|
||||
<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" />
|
||||
<span className="hidden sm:inline">Novo prompt</span>
|
||||
<span className="sm:hidden">Novo</span>
|
||||
@@ -231,7 +231,7 @@ export const PromptsView = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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" />
|
||||
Novo prompt
|
||||
</Button>
|
||||
@@ -287,7 +287,7 @@ export const PromptsView = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
|
||||
onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
|
||||
title="Editar"
|
||||
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ComponentPropsWithoutRef } from "react"
|
||||
import { type ComponentPropsWithoutRef, type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
@@ -12,23 +12,24 @@ export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
colorFrom = "#0055a4",
|
||||
colorTo = "#0d8ecf",
|
||||
style,
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
const bgSize = `${speed * 300}%`
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("animated-gradient-text inline-block font-semibold", className)}
|
||||
style={
|
||||
{
|
||||
"--bg-size": `${speed * 300}%`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
} as React.CSSProperties
|
||||
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
|
||||
backgroundSize: `${bgSize} 100%`,
|
||||
animation: "gradient-text-shift 12s ease-in-out infinite",
|
||||
...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}
|
||||
>
|
||||
{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 {
|
||||
/**
|
||||
* The size of the border beam.
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* The duration of the border beam.
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* The delay of the border beam.
|
||||
*/
|
||||
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
|
||||
size?: number;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
colorFrom?: string;
|
||||
colorTo?: string;
|
||||
transition?: Transition;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
reverse?: boolean;
|
||||
initialOffset?: number;
|
||||
borderWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Border Beam (Magic UI) — mesma lógica do registry oficial; estilos de máscara/borda
|
||||
* em inline CSS para compatibilidade com Tailwind 3 (o doc do Magic UI usa utilitários v4).
|
||||
*/
|
||||
export const BorderBeam = ({
|
||||
className,
|
||||
size = 50,
|
||||
@@ -62,30 +34,33 @@ export const BorderBeam = ({
|
||||
initialOffset = 0,
|
||||
borderWidth = 1,
|
||||
}: BorderBeamProps) => {
|
||||
return (
|
||||
<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]"
|
||||
style={
|
||||
{
|
||||
"--border-beam-width": `${borderWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute aspect-square",
|
||||
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
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)`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
...style,
|
||||
} as MotionStyle
|
||||
}
|
||||
backgroundImage: `linear-gradient(to left, ${colorFrom}, ${colorTo}, transparent)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={maskWrapperStyle}
|
||||
>
|
||||
<motion.div
|
||||
className={cn("absolute aspect-square", className)}
|
||||
style={{ ...beamGradient, ...style } as MotionStyle}
|
||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||
animate={{
|
||||
offsetDistance: reverse
|
||||
@@ -101,5 +76,5 @@ export const BorderBeam = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface ButtonProps
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
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";
|
||||
|
||||
@@ -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>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<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 />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
@@ -36,3 +38,4 @@ const ScrollBar = React.forwardRef<
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
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 {
|
||||
:root {
|
||||
/* HGTX Codex - Light Theme */
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
/* ─── HGTX Commander — Design System Tokens (compartilhados) ─── */
|
||||
|
||||
/* 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-foreground: 222 47% 11%;
|
||||
--card-foreground: 209 11% 15%;
|
||||
|
||||
--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-hover: 210 100% 28%;
|
||||
--primary-pressed: 210 100% 24%;
|
||||
--primary-light: 210 55% 96%;
|
||||
|
||||
--secondary: 210 100% 45%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
/* Secondary neutro para botões */
|
||||
--secondary: 210 6% 60%;
|
||||
--secondary-foreground: 209 11% 15%;
|
||||
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
/* Muted — bg suave */
|
||||
--muted: 210 17% 96%;
|
||||
--muted-foreground: 209 7% 46%;
|
||||
|
||||
--accent: 190 100% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
/* Accent = fundo nav ativo */
|
||||
--accent: 210 70% 94%;
|
||||
--accent-foreground: 210 100% 33%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 71% 45%;
|
||||
/* Sucesso: #28a745 */
|
||||
--success: 134 61% 41%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 222 47% 11%;
|
||||
--warning-foreground: 209 11% 15%;
|
||||
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 190 100% 45%;
|
||||
/* Borda: #e5e7eb */
|
||||
--border: 220 13% 91%;
|
||||
/* --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-background: 210 40% 99%;
|
||||
--sidebar-foreground: 222 47% 11%;
|
||||
--sidebar-primary: 190 100% 45%;
|
||||
/* Sidebar — Light */
|
||||
--sidebar-background: 210 17% 98%;
|
||||
--sidebar-foreground: 209 11% 15%;
|
||||
--sidebar-primary: 210 100% 33%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 40% 95%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 88%;
|
||||
--sidebar-ring: 190 100% 45%;
|
||||
--sidebar-accent: 210 70% 94%;
|
||||
--sidebar-accent-foreground: 210 100% 33%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 210 100% 33%;
|
||||
|
||||
/* Custom gradients - Light */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 45%) 0%, hsl(210 100% 45%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(210 40% 95%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.15);
|
||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.12);
|
||||
/* Tokens extras — Light */
|
||||
--bubble-bot: 209 36% 96%;
|
||||
--nav-active: 210 70% 94%;
|
||||
--input-bg: 0 0% 100%;
|
||||
--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-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
@@ -70,62 +131,85 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* HGTX Codex - Dark Theme */
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 200 100% 95%;
|
||||
/* ─── Dark Theme ─── */
|
||||
/* Fundo principal = mesma família da sidebar (216 50% 9%), levemente mais profundo */
|
||||
--background: 216 48% 8%;
|
||||
--foreground: 0 0% 100%;
|
||||
|
||||
--card: 222 40% 8%;
|
||||
--card-foreground: 200 100% 95%;
|
||||
/* Cards/painéis um tom acima do fundo para hierarquia sutil */
|
||||
--card: 216 42% 11%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
|
||||
--popover: 222 45% 10%;
|
||||
--popover-foreground: 200 100% 95%;
|
||||
--popover: 216 42% 11%;
|
||||
--popover-foreground: 0 0% 100%;
|
||||
|
||||
--primary: 190 100% 50%;
|
||||
--primary-foreground: 222 47% 5%;
|
||||
/* Azul primário dark — vibrante em fundo escuro */
|
||||
--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-foreground: 222 47% 5%;
|
||||
--secondary: 216 38% 13%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 222 30% 15%;
|
||||
--muted-foreground: 200 20% 65%;
|
||||
--muted: 216 38% 13%;
|
||||
--muted-foreground: 212 9% 58%;
|
||||
|
||||
--accent: 190 100% 60%;
|
||||
--accent-foreground: 222 47% 5%;
|
||||
/* Accent dark */
|
||||
--accent: 210 45% 16%;
|
||||
--accent-foreground: 210 85% 62%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 200 100% 95%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 71% 45%;
|
||||
--success-foreground: 222 47% 5%;
|
||||
/* Sucesso dark: #23d160 */
|
||||
--success: 141 71% 48%;
|
||||
--success-foreground: 216 48% 8%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 222 47% 5%;
|
||||
--warning-foreground: 216 48% 8%;
|
||||
|
||||
--border: 222 30% 18%;
|
||||
--input: 222 30% 18%;
|
||||
--ring: 190 100% 50%;
|
||||
/* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
|
||||
--border: 216 18% 26%;
|
||||
/* --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-background: 222 45% 7%;
|
||||
--sidebar-foreground: 200 100% 90%;
|
||||
--sidebar-primary: 190 100% 50%;
|
||||
--sidebar-primary-foreground: 222 47% 5%;
|
||||
--sidebar-accent: 222 35% 12%;
|
||||
--sidebar-accent-foreground: 190 100% 60%;
|
||||
--sidebar-border: 222 30% 15%;
|
||||
--sidebar-ring: 190 100% 50%;
|
||||
/* Sidebar — Dark */
|
||||
--sidebar-background: 216 50% 9%;
|
||||
--sidebar-foreground: 0 0% 100%;
|
||||
--sidebar-primary: 210 90% 52%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 45% 16%;
|
||||
--sidebar-accent-foreground: 210 85% 62%;
|
||||
--sidebar-border: 212 12% 21%;
|
||||
--sidebar-ring: 210 90% 52%;
|
||||
|
||||
/* Tokens extras — Dark */
|
||||
--bubble-bot: 216 36% 14%;
|
||||
--nav-active: 210 45% 16%;
|
||||
--input-bg: 216 40% 10%;
|
||||
--placeholder: 212 10% 31%;
|
||||
--btn-secondary-border: 216 16% 30%;
|
||||
--border-subtle: 216 22% 20%;
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
--shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.55);
|
||||
--focus-ring: rgba(51, 136, 255, 0.35);
|
||||
|
||||
/* Gradientes — Dark */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(210 90% 52%) 0%, hsl(210 100% 40%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(216 42% 11%) 0%, hsl(216 48% 8%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(210 90% 52% / 0.35);
|
||||
--glow-blue: 0 0 30px hsl(210 100% 40% / 0.28);
|
||||
|
||||
/* 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-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
|
||||
.theme {
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
--animate-gradient: gradient 8s linear infinite;
|
||||
@@ -144,12 +228,21 @@
|
||||
@apply border-border transition-colors;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
#root {
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -179,15 +272,22 @@
|
||||
|
||||
.cyber-border {
|
||||
@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 {
|
||||
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent;
|
||||
@apply bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.animated-gradient-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -246,6 +346,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-text-shift {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes shiny-text {
|
||||
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,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
@@ -27,12 +28,12 @@ export function AppSidebar() {
|
||||
<>
|
||||
{/* Logo */}
|
||||
<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" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -61,16 +62,36 @@ export function AppSidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Collapse button - desktop only */}
|
||||
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto space-y-2 border-t border-sidebar-border",
|
||||
collapsed ? "p-3" : "p-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="nav-item 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
|
||||
className={cn(
|
||||
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
|
||||
collapsed && "rotate-180"
|
||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||
collapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Recolher</span>}
|
||||
|
||||
@@ -674,7 +674,7 @@ export default function Financas() {
|
||||
variant="outline"
|
||||
className={
|
||||
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"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
/** Operações de IA (imagem, áudio, transcrição) podem levar vários minutos. */
|
||||
export const LONG_RUNNING_REQUEST_TIMEOUT_MS = 300_000;
|
||||
|
||||
class ApiService {
|
||||
private readonly axiosInstance: AxiosInstance;
|
||||
private readonly apiKey: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@@ -135,7 +135,8 @@ class AudioGenerationService {
|
||||
try {
|
||||
const response = await apiService.post<AudioGenerationResponse>(
|
||||
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;
|
||||
} 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 { apiService } from './api';
|
||||
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||
|
||||
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
|
||||
|
||||
@@ -127,7 +127,8 @@ class ImageGenerationService {
|
||||
try {
|
||||
const response = await apiService.post<ImageGenerationResponse>(
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
|
||||
import { apiService } from './api';
|
||||
import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
|
||||
|
||||
export interface TranscriptionResponse {
|
||||
success: boolean;
|
||||
@@ -82,7 +82,8 @@ class TranscriptionService {
|
||||
try {
|
||||
const response = await apiService.postFormData<TranscriptionResponse>(
|
||||
this.TRANSCRIPTION_ENDPOINT,
|
||||
formData
|
||||
formData,
|
||||
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_BASE_URL_COMMANDER: string
|
||||
readonly VITE_API_KEY: string
|
||||
readonly VITE_USER_EMAIL: string
|
||||
readonly VITE_ESTABELECIMENTO_ID: string
|
||||
|
||||
+41
-5
@@ -25,6 +25,9 @@ export default {
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
hover: "hsl(var(--primary-hover))",
|
||||
pressed: "hsl(var(--primary-pressed))",
|
||||
light: "hsl(var(--primary-light))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
@@ -68,11 +71,33 @@ export default {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
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: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
sm: "var(--radius-sm)",
|
||||
md: "var(--radius-md)",
|
||||
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: {
|
||||
"accordion-down": {
|
||||
@@ -85,14 +110,24 @@ export default {
|
||||
},
|
||||
"pulse-glow": {
|
||||
"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)"
|
||||
},
|
||||
"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)"
|
||||
},
|
||||
},
|
||||
"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": {
|
||||
"0%": { transform: "translateX(100%)", opacity: "0" },
|
||||
"100%": { transform: "translateX(0)", opacity: "1" },
|
||||
@@ -106,6 +141,7 @@ export default {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"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",
|
||||
"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