19 Commits

Author SHA1 Message Date
Vitex Tecnologia 2ff508f471 permitir o usuario a ver o fechamento com o status em aberto 2026-06-05 16:38:24 -03:00
Vitex Tecnologia 521ca9743f ajuste responsividade modal de alterar competencia da tarefa 2026-06-05 13:46:40 -03:00
Vitex Tecnologia 8a77c0dd31 implementacao de logs de auditoria e alterar multiplos registros de competencia 2026-06-01 17:19:00 -03:00
Vitex Tecnologia 34ae317fed Modulo Commander CSV 2026-05-28 11:06:46 -03:00
Vitex Tecnologia 422107331b melhoria filtro de conclusao fechamento 2026-05-22 17:58:35 -03:00
Vitex Tecnologia fc5fb3cc15 atualizacao tela de fechamento 2026-05-20 16:38:07 -03:00
Vitex Tecnologia 0b5c7350c5 atualizacoes modulo Score e Commander 2026-05-20 10:53:17 -03:00
Vitex Tecnologia 6acfee2c76 Atualizacao visual HGTX Commander 2026-05-16 19:29:25 -03:00
Vitex Tecnologia b4622c9166 atualizacoes API Integracao 2026-05-15 17:13:52 -03:00
Vitex Tecnologia 517c9751bc novas features, geracao pdf, regras de usuarios, novo perfil de acesso 2026-05-13 17:43:25 -03:00
Vitex Tecnologia 343aae602f novas atualizações na exportacao planilha, tela de listar fechamento, filtros 2026-05-07 11:34:55 -03:00
Vitex Tecnologia 1c1e62c7db atualizacao na conclusao do fechamento 2026-05-04 11:59:56 -03:00
Vitex Tecnologia 7859230468 novas atualizacoes 2026-05-04 10:51:21 -03:00
Vitex Tecnologia dee9ecfbca remover dist zip 2026-04-29 10:17:17 -03:00
Vitex Tecnologia d570029890 nova função de alterar task de competencia 2026-04-29 10:13:17 -03:00
Vitex Tecnologia b19e2963c4 ajuste env 2026-04-28 11:03:01 -03:00
Vitex Tecnologia f2cc21f282 ajustes 2026-04-27 21:49:40 -03:00
Vitex Tecnologia 4c28862c5c novas atualizacoes do sistema de fechamento 2026-04-27 16:50:27 -03:00
Vitex Tecnologia 6da067c641 atualizacoes modulo fechamento 2026-04-26 22:54:56 -03:00
80 changed files with 14864 additions and 831 deletions
+2 -4
View File
@@ -14,7 +14,5 @@ VITE_API_KEY=your-api-key-here
VITE_USER_EMAIL=user@example.com VITE_USER_EMAIL=user@example.com
VITE_ESTABELECIMENTO_ID=1 VITE_ESTABELECIMENTO_ID=1
# Instruções: # API do módulo de fechamento (Commander)
# 1. Copie este arquivo para .env VITE_API_BASE_URL_COMMANDER=https://hgtx-opencommander-api.goutron.com.br/api
# 2. Substitua os valores acima com as configurações reais
# 3. NUNCA commite o arquivo .env no git
BIN
View File
Binary file not shown.
+385
View File
@@ -0,0 +1,385 @@
# Design System — HGTX Commander
> Referência técnica de tokens e padrões — light + dark.
> **Fonte da verdade (código):** `src/index.css` + `tailwind.config.ts`.
> Tema aplicado via `next-themes` (`class` no `<html>`, `storageKey: hgtx-commander-theme`).
## Cor de marca (azul HGTX)
Azul institucional usado em botões primários, links, anéis de foco e destaques do Commander.
| Papel | Light | Dark |
|-------|-------|------|
| **Primária** | `hsl(210 100% 33%)` · **#0054A8** | `hsl(210 90% 52%)` · **#1685F3** |
| Hover | `hsl(210 100% 28%)` · #00478F | `hsl(210 95% 46%)` · #0675E5 |
| Pressed | `hsl(210 100% 24%)` · #003D7A | `hsl(210 100% 40%)` · #0066CC |
| Fundo sutil | `hsl(210 55% 96%)` · #EFF5FB | `hsl(210 50% 14%)` · #122033 |
| Focus ring | `rgba(0, 85, 164, 0.28)` | `rgba(51, 136, 255, 0.35)` |
Referência histórica de marca: ~`#0055A4` / `#004080` — no código o token `--primary` (light) resolve para **#0054A8**.
**Tailwind / shadcn:** `bg-primary`, `text-primary`, `border-primary`, `ring-ring` (usa `--ring`, alinhado ao primário).
---
## Fonte
Inter — Google Fonts
Pesos: 400, 500, 600, 700
Import: `https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap`
Token: `--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif`
---
## Cores
Valores em **HSL sem vírgula** (formato shadcn: `hsl(var(--token))`). Hex entre parênteses para referência visual.
### Primária e estados
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--primary` | 210 100% 33% · #0054A8 | 210 90% 52% · #1685F3 | `bg-primary` `text-primary` | CTAs, enviar, links ativos |
| `--primary-foreground` | 0 0% 100% · #FFFFFF | 0 0% 100% · #FFFFFF | `text-primary-foreground` | Texto sobre primário |
| `--primary-hover` | 210 100% 28% · #00478F | 210 95% 46% · #0675E5 | `bg-primary-hover` | Hover botões primários |
| `--primary-pressed` | 210 100% 24% · #003D7A | 210 100% 40% · #0066CC | `bg-primary-pressed` | Active / pressed |
| `--primary-light` | 210 55% 96% · #EFF5FB | 210 50% 14% · #122033 | `bg-primary-light` | Fundo de destaque suave |
| `--ring` | 210 100% 33% | 210 90% 52% | `ring-ring` | Focus visível (inputs) |
| `--focus-ring` | rgba(0, 85, 164, 0.28) | rgba(51, 136, 255, 0.35) | sombra manual | Focus em componentes custom |
### Semânticas (feedback)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--success` | 134 61% 41% · #29A847 | 141 71% 48% · #23D160 | `bg-success` `text-success` | Online, confirmação |
| `--success-foreground` | 0 0% 100% | 216 48% 8% | `text-success-foreground` | Texto sobre success |
| `--destructive` | 0 84% 60% | 0 84% 60% | `bg-destructive` | Erro, excluir |
| `--destructive-foreground` | 0 0% 100% | 0 0% 100% | `text-destructive-foreground` | Texto sobre destructive |
| `--warning` | 38 92% 50% | 38 92% 50% | `bg-warning` `text-warning` | Alertas, badge “Pessoal” |
| `--warning-foreground` | 209 11% 15% | 216 48% 8% | `text-warning-foreground` | Texto sobre warning |
### Superfícies (page, card, sidebar)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--background` | 0 0% 100% · #FFFFFF | 216 48% 8% · #0B121E | `bg-background` | Fundo principal / chat |
| `--foreground` | 209 11% 15% · #22262A | 0 0% 100% · #FFFFFF | `text-foreground` | Texto principal |
| `--card` | 0 0% 100% · #FFFFFF | 216 42% 11% · #101A28 | `bg-card` | Cards, modais, composer |
| `--card-foreground` | 209 11% 15% | 0 0% 100% | `text-card-foreground` | Texto em cards |
| `--popover` | 0 0% 100% | 216 42% 11% | `bg-popover` | Dropdowns, selects |
| `--sidebar-background` | 210 17% 98% · #F8FAFB | 216 50% 9% · #0B1522 | `bg-sidebar` | Sidebar Commander / módulos |
| `--sidebar-foreground` | 209 11% 15% | 0 0% 100% | `text-sidebar-foreground` | Texto na sidebar |
| `--sidebar-border` | 220 13% 91% · #E5E7EB | 212 12% 21% · #2F363F | `border-sidebar-border` | Divisor da sidebar |
| `--sidebar-accent` | 210 70% 94% · #E3EEFC | 210 45% 16% · #16283D | `bg-sidebar-accent` | Item de nav ativo / hover |
| `--sidebar-accent-foreground` | 210 100% 33% | 210 85% 62% · #5BA3EB | `text-sidebar-accent-foreground` | Texto no item ativo |
| `--muted` | 210 17% 96% · #F3F6F8 | 216 38% 13% · #15202E | `bg-muted` | Fundos secundários |
| `--accent` | 210 70% 94% | 210 45% 16% | `bg-accent` | Hover suave, chips |
| `--accent-foreground` | 210 100% 33% | 210 85% 62% | `text-accent-foreground` | Texto em accent |
### Tokens extras (chat e UI)
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Uso |
|-----------|-------------------|------------------|-----|
| `--bubble-bot` | 209 36% 96% | 216 36% 14% | Bolha do assistente |
| `--nav-active` | 210 70% 94% | 210 45% 16% | Nav ativo (alias visual do accent) |
| `--input-bg` | 0 0% 100% | 216 40% 10% | Fundo de campos (referência) |
| `--secondary` | 210 6% 60% | 216 38% 13% | Neutro secundário (evitar texto em badge dark) |
| `--secondary-foreground` | 209 11% 15% | 0 0% 100% | Texto sobre secondary |
### Bordas e inputs (shadcn)
No shadcn, `--input` é a **cor da borda** de Input / Select / Textarea, não o fundo do campo.
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--border` | 220 13% 91% · #E5E7EB | 216 18% 26% · #36404E | `border-border` | Divisores, cards |
| `--input` | 220 9% 83% · #D0D2D8 | 216 16% 30% · #404A59 | `border-input` | Borda de inputs |
| `--border-subtle` | 220 13% 95% | 216 22% 20% | custom | Bordas muito suaves |
| `--btn-secondary-border` | 220 9% 83% | 216 16% 30% | custom | Outline secundário |
### Texto secundário
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|-----------|-------------------|------------------|-----------------|-----|
| `--muted-foreground` | 209 7% 46% · #6D767E | 212 9% 58% · #8B949E | `text-muted-foreground` | Subtítulos, hints |
| `--placeholder` | 220 9% 66% | 212 10% 31% | `text-placeholder` / `placeholder:` | Placeholders |
### Gradientes e glow (efeitos)
| Token | Light | Dark |
|-------|-------|------|
| `--gradient-cyber` | `135deg`, hsl(210 100% 33%) → hsl(210 90% 42%) | hsl(210 90% 52%) → hsl(210 100% 40%) |
| `--gradient-subtle` | #FFF → hsl(210 17% 98%) | hsl(216 42% 11%) → hsl(216 48% 8%) |
| `--glow-cyan` / `--glow-blue` | sombra azul suave 1215% opac. | sombra azul 2835% opac. |
Classes utilitárias: `.gradient-text`, `.cyber-glow`, `.glass-effect` em `src/index.css`.
---
## Tipografia
> Mesma escala em light e dark.
| Nível | Tamanho | Peso | Line Height | Classe Semântica |
|-------|---------|------|-------------|------------------|
| Display / Commander | 32px (`--fs-display`) | 700 | 1.25 (`--lh-tight`) | text-display |
| Heading 1 — Modal | 24px (`--fs-h1`) | 600 | 1.25 | text-h1 |
| Heading 2 — Seção | 18px (`--fs-h2`) | 600 | 1.5 (`--lh-body`) | text-h2 |
| Heading 3 | 15px (`--fs-h3`) | — | — | text-h3 |
| Body | 14px (`--fs-body`) | 400 | 1.5 | text-body |
| Small / Timestamp | 12px (`--fs-small`) | 400 | — | text-small |
| Caption | 11px (`--fs-caption`) | 500 | — | text-caption |
| Nav link | 13px | 500 (`--fw-medium`) | — | text-nav-link |
| Nav brand | 15px | 700 (`--fw-bold`) | — | text-nav-brand |
| Brand lockup (tagline) | 10px | 600 | — | text-brand-tag |
| Brand lockup (nome) | 16px | 700 | — | text-brand-name |
| Hero título (catálogo) | clamp(28px, 4vw, 42px) | 700 | 1.25 | text-hero |
| Section title | 28px | 700 | — | text-section-title |
Tokens de peso: `--fw-regular` 400, `--fw-medium` 500, `--fw-semibold` 600, `--fw-bold` 700.
Tokens de line-height: `--lh-tight` 1.25, `--lh-body` 1.5, `--lh-relaxed` 1.65.
---
## Espaçamento
Base unit: **4px**
| Token | Valor | Classe Semântica |
|-------|-------|------------------|
| --space-1 | 4px | p-1 / m-1 / gap-1 |
| --space-2 | 8px | p-2 / m-2 / gap-2 |
| --space-3 | 12px | p-3 / m-3 / gap-3 |
| --space-4 | 16px | p-4 / m-4 / gap-4 |
| --space-5 | 20px | p-5 / m-5 / gap-5 |
| --space-6 | 24px | p-6 / m-6 / gap-6 |
| --space-7 | 32px | p-7 / m-7 / gap-7 |
| --space-8 | 48px | p-8 / m-8 / gap-8 |
| --space-9 | 64px | p-9 / m-9 / gap-9 |
| --section-y | 64px | py-section |
| --section-x | 40px | px-section |
| --nav-h | 56px | h-nav |
| --container | 1200px | max-w-container |
---
## Border Radius
| Token | Valor | Classe Semântica | Uso |
|-------|-------|------------------|-----|
| --radius-sm | 6px | rounded-sm | Chat items, detalhes |
| --radius-md | 8px | rounded-md | Botões, inputs, nav items, badges |
| --radius-lg | 12px | rounded-lg | Cards, bolhas, demo wrap |
| --radius-xl | 16px | rounded-xl | Modal Personalidade |
| --radius-pill | 999px | rounded-pill | Chat bar (composer) |
| 50% | — | rounded-full | Botão send circular |
---
## Sombras
| Token CSS | Light | Dark | Classe Tailwind | Uso |
|-----------|-------|------|-----------------|-----|
| `--shadow-card` | `0 4px 12px rgba(0,0,0,0.06)` | `0 4px 16px rgba(0,0,0,0.35)` | `shadow-card` | Cards elevados |
| `--shadow-modal` | `0 16px 40px rgba(0,0,0,0.12)` | `0 24px 48px rgba(0,0,0,0.55)` | `shadow-modal` | Modais, dialogs |
Glow Online (badge): `box-shadow: 0 0 8px hsl(var(--success) / 0.5)`.
---
## Componentes
> Use referências a tokens — valores resolvem para light/dark conforme o tema ativo.
### Botão Primary (HGTX — Nova Sessão / Salvar Perfil)
```
bg-primary hover:bg-primary-hover active:bg-primary-pressed
text-white rounded-md px-[18px] py-[10px]
text-[13px] font-semibold inline-flex items-center gap-2
border-none cursor-pointer transition-[background] duration-200
disabled:opacity-45 disabled:cursor-not-allowed
```
### Botão Primary (catálogo / hero)
```
bg-primary hover:bg-primary-hover active:bg-primary-pressed active:translate-y-px
text-white rounded-md px-6 py-3 text-sm font-semibold
border-none cursor-pointer transition-all duration-200
```
### Botão Outline (Nova Pasta)
```
bg-transparent text-primary border border-btn-secondary
rounded-md px-[18px] py-[10px] text-[13px] font-medium
inline-flex items-center gap-2 cursor-pointer
hover:border-secondary transition-[border-color] duration-200
```
### Botão Secondary (catálogo)
```
bg-transparent text-primary border border-btn-secondary
rounded-md px-6 py-3 text-sm font-medium
hover:border-secondary hover:bg-black/[0.03] (light) / hover:bg-white/[0.04] (dark)
transition-all duration-200 cursor-pointer
```
### Botão Ghost (Cancelar)
```
bg-transparent text-secondary border-none rounded-md px-[14px] py-2
text-[13px] cursor-pointer
hover:text-primary hover:bg-black/[0.04] (light) / hover:bg-white/[0.05] (dark)
transition-all duration-200
```
### Botão Send (circular)
```
w-10 h-10 rounded-full bg-primary text-white border-none
flex items-center justify-center text-base cursor-pointer
```
### Nav Sticky (catálogo DS)
```
sticky top-0 z-[100] h-nav flex items-center justify-between px-section
bg-nav backdrop-blur-[12px] border-b border-divider
```
### Nav CTA
```
bg-primary hover:bg-primary-hover text-white rounded-md
px-[18px] py-2 text-[13px] font-semibold border-none cursor-pointer
```
### Nav Item (sidebar)
```
flex items-center gap-3 px-3 py-2 rounded-md text-[13px] text-secondary
```
### Nav Item Ativo
```
bg-nav-active text-primary
```
### Nav Chat Item (lista conversas)
```
flex justify-between items-center px-3 py-2 text-xs text-secondary rounded-sm
/* timestamp */ text-[11px] text-placeholder
```
### Input / Select / Textarea
```
font-sans text-sm px-[14px] py-[10px] rounded-md
border border-divider bg-input text-primary outline-none w-full
placeholder:text-placeholder
transition-[border-color,box-shadow] duration-200
focus:border-primary focus:shadow-[0_0_0_3px_var(--color-focus-ring)]
disabled:opacity-50 disabled:cursor-not-allowed
```
### Textarea (modal)
```
[classes input] min-h-[120px] resize-y
```
### Input Search (com ícone)
```
relative /* wrap */
[input] pl-9
/* ícone */ absolute left-3 top-1/2 -translate-y-1/2 text-placeholder text-sm
```
### Chat Composer Bar
```
flex items-center gap-3 py-2 pr-2 pl-5
border border-divider rounded-pill bg-input max-w-[560px]
/* input interno */ flex-1 border-none bg-transparent text-sm text-primary outline-none placeholder:text-placeholder
/* + btn-send */
```
### Badge Online
```
inline-flex items-center gap-2 text-small font-medium text-secondary
/* dot */ w-2 h-2 rounded-full bg-success shadow-[0_0_8px_rgba(35,209,96,0.5)]
```
### Badge Self-Hosted
```
inline-flex items-center gap-2 text-caption text-placeholder
px-3 py-2 border border-divider rounded-md
```
### Card Modal (Personalidade)
```
max-w-[480px] p-6 bg-surface-card border border-divider rounded-xl shadow-modal
```
### Card Modal Header
```
flex justify-between items-start mb-4
/* título */ text-h2 font-semibold
```
### Card Modal Close
```
w-8 h-8 border-none rounded-md bg-transparent text-secondary text-lg cursor-pointer
hover:text-primary hover:bg-black/[0.05] (light) / hover:bg-white/[0.06] (dark)
```
### Card Modal Actions
```
flex justify-end gap-3 mt-5
/* Cancelar: btn-hgtx-ghost | Salvar: btn-hgtx-primary */
```
### Bolha Bot (boas-vindas)
```
max-w-[420px] px-5 py-4 bg-bubble-bot rounded-lg
text-sm text-secondary leading-relaxed
```
### Layout Demo (3 colunas)
```
grid grid-cols-[200px_280px_1fr] border border-divider rounded-lg overflow-hidden min-h-[320px]
/* sidebars */ bg-sidebar p-4 border-r border-divider
/* main */ bg-page p-5 flex flex-col items-center justify-center
```
### Brand Lockup (sidebar)
```
text-[10px] font-bold uppercase tracking-[0.12em] text-secondary mb-4
/* nome */ block text-sm text-primary tracking-[0.06em]
```
### Folder Label (sidebar)
```
text-xs font-medium text-secondary py-2 mt-3
```
---
## Tema — como aplicar
1. **Tokens light** em `:root` e **tokens dark** em `.dark` — ver `src/index.css`.
2. **App:** `ThemeProvider` (`next-themes`) com `attribute="class"`, `storageKey="hgtx-commander-theme"`, temas `light` | `dark`.
3. **Tailwind:** cores semânticas em `tailwind.config.ts``hsl(var(--primary))`, etc.
Tokens **compartilhados** (não mudam com o tema): `--fs-*`, `--space-*`, `--radius-*`, `--fw-*`, `--lh-*`, `--section-*`, `--nav-h`, `--container`.
Texto sobre botões primários: sempre `--primary-foreground` (`#FFFFFF`).
### Mapeamento rápido (legado → atual)
| Doc / HTML antigo | Token atual |
|-------------------|-------------|
| `#0061ff` / `#1a73e8` | `--primary`#0054A8 (light) / #1685F3 (dark) |
| `bg-page` | `bg-background` |
| `bg-sidebar` | `bg-sidebar` (`--sidebar-background`) |
| `text-secondary` (corpo) | `text-muted-foreground` |
| `border-divider` | `border-border` |
---
*Atualizado para refletir `src/index.css` (HGTX Commander redesign).*
*Para uso como referência no Cursor e agentes de código.*
+5 -5
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HGTX Codex - AI Interface System</title> <title>HGTX Commander</title>
<meta name="description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" /> <meta name="description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
<meta name="author" content="HGTX Codex" /> <meta name="author" content="HGTX Commander" />
<meta property="og:title" content="HGTX Codex - AI Interface System" /> <meta property="og:title" content="HGTX Commander" />
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" /> <meta property="og:description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content="/favicon.png" /> <meta property="og:image" content="/favicon.png" />
+1 -1
View File
@@ -51,7 +51,7 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"motion": "^12.36.0", "motion": "^12.38.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+11 -16
View File
@@ -4,12 +4,12 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect"; import Redirect from "./pages/Redirect";
import { GlobalFunctions } from "./GlobalFunctions";
import React from "react"; import React from "react";
import CommanderApp from "./modules/commander/App";
import IntelligenceIAApp from "./modules/intelligence-ia/App"; import IntelligenceIAApp from "./modules/intelligence-ia/App";
import IntelligenceScoreApp from "./modules/intelligence-score/App";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -20,7 +20,7 @@ const App = () => (
defaultTheme="light" defaultTheme="light"
themes={["light", "dark"]} themes={["light", "dark"]}
enableSystem={false} enableSystem={false}
storageKey="hgtx-codex-theme" storageKey="hgtx-commander-theme"
enableColorScheme enableColorScheme
> >
<TooltipProvider> <TooltipProvider>
@@ -28,18 +28,15 @@ const App = () => (
<Sonner /> <Sonner />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route <Route path="/redir/:gsikey" element={<Redirect />} />
path="/redir/:gsikey"
element={<Redirect />}
/>
{/* Módulo Intelligence IA como subpath*/} <Route path="/commander/*" element={<CommanderApp />} />
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} /> <Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
<Route path="/intelligence-score/*" element={<IntelligenceScoreApp />} />
<Route path="/*" element={<Index />} /> <Route path="/" element={<Navigate to="/commander" replace />} />
<Route path="*" element={<NotFound />} />
<Route path="/404" element={<NotFound />} /> <Route path="/404" element={<NotFound />} />
<Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</TooltipProvider> </TooltipProvider>
@@ -49,12 +46,10 @@ const App = () => (
export default App; export default App;
const HGTXLogin = () => {
const HGTXLogin = () =>{
React.useEffect(() => { React.useEffect(() => {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE); window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
}); });
return(<></>); return <></>;
} };
+2 -2
View File
@@ -69,7 +69,7 @@ export const ChatHeader = ({
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between"> <header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-4"> <div className="flex items-center gap-2 md:gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" /> <div className="w-2 h-2 rounded-full bg-success animate-online-pulse" />
<span className="text-sm text-muted-foreground">Online</span> <span className="text-sm text-muted-foreground">Online</span>
</div> </div>
@@ -155,7 +155,7 @@ export const ChatHeader = ({
</div> </div>
<div className="hidden md:block text-sm text-muted-foreground"> <div className="hidden md:block text-sm text-muted-foreground">
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"} {showModelSelector ? "Modelo de linguagem avançado" : "HGTX Commander"}
</div> </div>
</header> </header>
); );
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, type ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { authMeService } from "@/services/fechamento/authMe";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
type AuthGateProps = {
children: ReactNode;
};
const CORE_URL_FALLBACK = "https://core.hgtx.com.br";
export function AuthGate({ children }: AuthGateProps) {
const {
loading,
hasAccess,
reason,
errorMessage,
setAuthState,
} = useAuthAccess();
useEffect(() => {
let mounted = true;
const bootstrapAccess = async () => {
const isLogado = GlobalFunctions.isUsuarioLogado();
if (!isLogado) {
setAuthState({ loading: false, isAuthenticated: false, hasAccess: false });
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || CORE_URL_FALLBACK);
return;
}
let userEmail = GlobalFunctions.getUsuarioLogado().email;
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (typeof transferEmail === "string" && transferEmail.trim()) {
userEmail = transferEmail.trim();
}
}
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise((resolve) => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao obter token para validação de acesso:", error);
}
}
if (!userEmail) {
setAuthState({
loading: false,
isAuthenticated: false,
hasAccess: false,
reason: "erro",
errorMessage: "Não foi possível identificar o e-mail do usuário logado.",
});
return;
}
try {
const me = await authMeService.getMe(userEmail);
if (!mounted) {
return;
}
if (!me) {
const estabelecimentoCodigo = String(
GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
).trim();
if (estabelecimentoCodigo) {
try {
const bootstrap = await authMeService.getBootstrapStatus(estabelecimentoCodigo);
if (bootstrap.requiresBootstrap) {
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "bootstrap",
papel: null,
me: null,
errorMessage: estabelecimentoCodigo,
});
return;
}
} catch {
// Mantém fluxo padrão de não cadastrado caso não consiga consultar bootstrap.
}
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "nao_cadastrado",
papel: null,
me: null,
errorMessage: null,
});
return;
}
if (!me.estaAtivo) {
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "inativo",
papel: me.papel,
me,
errorMessage: null,
});
return;
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: true,
papel: me.papel,
me,
reason: null,
errorMessage: null,
});
} catch (error) {
if (!mounted) {
return;
}
setAuthState({
loading: false,
isAuthenticated: true,
hasAccess: false,
reason: "erro",
errorMessage: error instanceof Error ? error.message : "Erro ao validar acesso.",
});
}
};
bootstrapAccess();
return () => {
mounted = false;
};
}, [setAuthState]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
if (!hasAccess) {
return <NoAccessScreen reason={reason} errorMessage={errorMessage} />;
}
return <>{children}</>;
}
+158
View File
@@ -0,0 +1,158 @@
import { useMemo, useState } from "react";
import { Loader2, ShieldX } from "lucide-react";
import { BorderBeam } from "@/components/ui/border-beam";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { Label } from "@/components/ui/label";
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
import { authMeService } from "@/services/fechamento/authMe";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
type NoAccessScreenProps = {
reason: AccessBlockReason | null;
errorMessage?: string | null;
};
function getMessage(reason: AccessBlockReason | null): string {
if (reason === "inativo") {
return "Seu usuário está inativo neste módulo. Solicite a reativação ao administrador.";
}
if (reason === "erro") {
return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
}
return "Você não tem acesso a este módulo no momento. Solicite acesso ao administrador.";
}
export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
const [unidadeNome, setUnidadeNome] = useState("");
const [adminNome, setAdminNome] = useState("");
const [adminEmail, setAdminEmail] = useState("");
const [saving, setSaving] = useState(false);
/** Código do estabelecimento: prioriza área de transferência (Codex), depois o valor repassado pelo AuthGate. */
const estabelecimentoId = useMemo(() => {
const fromTransfer = String(
GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
).trim();
const fromGate = (errorMessage ?? "").trim();
return fromTransfer || fromGate;
}, [errorMessage]);
const handleBootstrap = async () => {
if (!estabelecimentoId) {
return;
}
if (!unidadeNome.trim() || !adminNome.trim() || !adminEmail.trim()) {
alert("Preencha nome da unidade, nome do admin e e-mail.");
return;
}
try {
setSaving(true);
await authMeService.bootstrapInitialize({
estabelecimentoId,
unidadeNome: unidadeNome.trim(),
adminNome: adminNome.trim(),
adminEmail: adminEmail.trim(),
});
window.location.reload();
} catch (error) {
alert(error instanceof Error ? error.message : "Erro ao inicializar ambiente.");
} finally {
setSaving(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="relative w-full max-w-xl overflow-hidden">
<CardHeader className="text-center">
{reason === "bootstrap" ? (
<>
<CardTitle>Bem-vindo! Configuração inicial</CardTitle>
<CardDescription className="text-base leading-relaxed text-muted-foreground">
Este é o <strong className="font-medium text-foreground">primeiro acesso</strong> ao Fechamento HGTX
para este estabelecimento. Em poucos passos você cadastra a unidade e o primeiro usuário administrador
para começar a usar o sistema.
</CardDescription>
</>
) : (
<>
<div className="mb-3 flex justify-center">
<ShieldX className="h-10 w-10 text-destructive" />
</div>
<CardTitle>Você não tem acesso a este módulo</CardTitle>
</>
)}
</CardHeader>
<CardContent className="space-y-3 text-center">
{reason !== "bootstrap" ? <p className="text-muted-foreground">{getMessage(reason)}</p> : null}
{reason === "bootstrap" ? (
<div className="space-y-4 rounded-md border p-4 text-left">
<div className="space-y-1">
<Label>Código do estabelecimento</Label>
<Input value={estabelecimentoId} disabled className="font-mono bg-muted" />
<p className="text-xs text-muted-foreground">Preenchimento automático.</p>
{!estabelecimentoId ? (
<p className="text-xs text-amber-700 dark:text-amber-200">
Não foi possível obter o código do estabelecimento. Abra o Intelligence Score a partir do Commander com o
estabelecimento carregado no transfer.
</p>
) : null}
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
<Input
id="bootstrap-unidade"
value={unidadeNome}
onChange={(e) => setUnidadeNome(e.target.value)}
placeholder="Ex.: Unidade Matriz"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-nome">Nome do primeiro admin</Label>
<Input
id="bootstrap-admin-nome"
value={adminNome}
onChange={(e) => setAdminNome(e.target.value)}
placeholder="Ex.: João Silva"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-email">E-mail do primeiro admin</Label>
<Input
id="bootstrap-admin-email"
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@empresa.com"
/>
</div>
<div className="flex justify-end">
<InteractiveHoverButton
type="button"
onClick={handleBootstrap}
disabled={saving || !estabelecimentoId}
className="disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
Inicializando...
</span>
) : (
"Iniciar ambiente"
)}
</InteractiveHoverButton>
</div>
</div>
) : null}
{reason !== "bootstrap" && errorMessage ? (
<p className="text-sm text-muted-foreground">{errorMessage}</p>
) : null}
</CardContent>
<BorderBeam duration={8} size={100} />
</Card>
</div>
);
}
+116 -73
View File
@@ -4,22 +4,43 @@ import { Textarea } from "@/components/ui/textarea";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { chatService } from "@/services/chat"; import { chatService } from "@/services/chat";
import { cn } from "@/lib/utils";
interface ChatInputProps { interface ChatInputProps {
onSendMessage: (message: string, files?: File[]) => void; onSendMessage: (message: string, files?: File[]) => void;
draftMessage?: string | null;
draftTrigger?: number;
onDraftApplied?: () => void;
} }
export const ChatInput = ({ onSendMessage }: ChatInputProps) => { const MOBILE_TEXTAREA_HEIGHT = 88;
const DEFAULT_TEXTAREA_HEIGHT = 72;
export const ChatInput = ({
onSendMessage,
draftMessage,
draftTrigger,
onDraftApplied,
}: ChatInputProps) => {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [attachedFiles, setAttachedFiles] = useState<File[]>([]); const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
const [textareaHeight, setTextareaHeight] = useState(60); const [textareaHeight, setTextareaHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const dragStartY = useRef<number>(0); const dragStartY = useRef<number>(0);
const dragStartHeight = useRef<number>(0); const dragStartHeight = useRef<number>(0);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
setIsDragging(true); setIsDragging(true);
dragStartY.current = e.clientY; dragStartY.current = e.clientY;
@@ -30,9 +51,9 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return; if (!isDragging) return;
const deltaY = dragStartY.current - e.clientY; // Inverted: moving up = positive const deltaY = dragStartY.current - e.clientY;
const newHeight = Math.min(400, Math.max(60, dragStartHeight.current + deltaY)); const newHeight = Math.min(400, Math.max(DEFAULT_TEXTAREA_HEIGHT, dragStartHeight.current + deltaY));
setTextareaHeight(newHeight); setTextareaHeight(newHeight);
}; };
@@ -41,16 +62,30 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
}; };
if (isDragging) { if (isDragging) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
} }
return () => { return () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
}, [isDragging, textareaHeight]); }, [isDragging, textareaHeight]);
useEffect(() => {
if (!draftMessage || draftTrigger == null) return;
setMessage(draftMessage);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
const end = draftMessage.length;
textarea.setSelectionRange(end, end);
});
onDraftApplied?.();
}, [draftMessage, draftTrigger, onDraftApplied]);
const handleSend = () => { const handleSend = () => {
if (message.trim() || attachedFiles.length > 0) { if (message.trim() || attachedFiles.length > 0) {
onSendMessage(message, attachedFiles); onSendMessage(message, attachedFiles);
@@ -70,7 +105,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const newFiles = [...attachedFiles, ...files]; const newFiles = [...attachedFiles, ...files];
// Valida quantidade máxima de anexos (5)
if (newFiles.length > chatService.getMaxAttachments()) { if (newFiles.length > chatService.getMaxAttachments()) {
toast({ toast({
title: "Limite de anexos excedido", title: "Limite de anexos excedido",
@@ -83,10 +117,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
return; return;
} }
// Valida tipos de arquivo e tamanho
const validFiles: File[] = []; const validFiles: File[] = [];
for (const file of files) { for (const file of files) {
// Valida tamanho (max 10MB por arquivo)
if (file.size > 10 * 1024 * 1024) { if (file.size > 10 * 1024 * 1024) {
toast({ toast({
title: "Arquivo muito grande", title: "Arquivo muito grande",
@@ -99,7 +131,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
validFiles.push(file); validFiles.push(file);
} }
// Valida formatos permitidos
const allFiles = [...attachedFiles, ...validFiles]; const allFiles = [...attachedFiles, ...validFiles];
const validation = chatService.validateAttachments(allFiles); const validation = chatService.validateAttachments(allFiles);
@@ -125,96 +156,108 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
setAttachedFiles(attachedFiles.filter((_, i) => i !== index)); setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
}; };
const canSend = Boolean(message.trim() || attachedFiles.length > 0);
const textareaStyleHeight = isMobile ? MOBILE_TEXTAREA_HEIGHT : textareaHeight;
return ( return (
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4"> <div className="w-full border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3"> <div className="mx-auto w-full max-w-4xl space-y-2 px-2 md:space-y-3 md:px-4">
{/* Attached Files Preview */}
{attachedFiles.length > 0 && ( {attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-1 md:gap-2"> <div className="flex flex-wrap gap-2">
{attachedFiles.map((file, index) => ( {attachedFiles.map((file, index) => (
<div <div
key={index} key={index}
className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1 md:py-1.5 rounded-lg bg-muted border border-border text-xs md:text-sm" className="flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/80 px-3 py-1.5 text-xs md:text-sm"
> >
<Paperclip className="w-3 h-3 text-primary" /> <Paperclip className="h-3.5 w-3.5 shrink-0 text-primary" />
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span> <span className="truncate font-medium">{file.name}</span>
<span className="hidden md:inline text-xs text-muted-foreground"> <span className="hidden text-xs text-muted-foreground md:inline">
({(file.size / 1024).toFixed(1)} KB) ({(file.size / 1024).toFixed(1)} KB)
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-4 w-4 ml-1" className="h-6 w-6 shrink-0"
onClick={() => removeFile(index)} onClick={() => removeFile(index)}
aria-label={`Remover ${file.name}`}
> >
<X className="w-3 h-3" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Message Input and Buttons */} <div className="glass-effect overflow-hidden rounded-2xl border border-border/60 shadow-sm">
<div className="flex gap-1 md:gap-2 items-end"> <div
<div className="flex-1 glass-effect rounded-xl overflow-hidden"> onMouseDown={handleMouseDown}
{/* Resize Handle - Hidden on mobile */} className={cn(
<div "hidden h-5 w-full cursor-ns-resize items-center justify-center border-b border-border/40 transition-colors md:flex",
onMouseDown={handleMouseDown} isDragging ? "bg-primary/15" : "hover:bg-primary/8",
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${ )}
isDragging ? 'bg-primary/20' : '' title="Arraste para aumentar a altura"
}`} >
title="Arraste para cima para aumentar" <GripHorizontal className="h-4 w-4 text-muted-foreground" />
>
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
</div>
{/* Textarea */}
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
style={{ height: window.innerWidth < 768 ? '80px' : `${textareaHeight}px` }}
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm md:text-base"
/>
</div>
</div> </div>
{/* Action Buttons */} <div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
<div className="flex gap-1 md:gap-2"> <Textarea
<input ref={textareaRef}
ref={fileInputRef} value={message}
type="file" onChange={(e) => setMessage(e.target.value)}
multiple onKeyDown={handleKeyDown}
className="hidden" placeholder="Digite sua mensagem..."
onChange={handleFileSelect} style={{ height: textareaStyleHeight }}
accept={chatService.getAllowedFileExtensions()} className="min-h-0 w-full resize-none border-0 bg-transparent p-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 md:text-base"
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
/> />
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/50 bg-muted/15 px-2 py-2 md:px-3 md:py-2.5">
<div className="flex min-w-0 items-center gap-1">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept={chatService.getAllowedFileExtensions()}
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
title="Anexar arquivo"
aria-label="Anexar arquivo"
>
<Paperclip className="h-4 w-4" />
</Button>
{attachedFiles.length > 0 && (
<span className="truncate text-xs text-muted-foreground">
{attachedFiles.length} anexo{attachedFiles.length > 1 ? "s" : ""}
</span>
)}
</div>
<Button <Button
variant="outline" type="button"
size="icon"
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
onClick={() => fileInputRef.current?.click()}
title="Anexar arquivo"
>
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
</Button>
<Button
onClick={handleSend} onClick={handleSend}
size="icon" size="icon"
className="h-9 w-9 md:h-11 md:w-11 cyber-glow" disabled={!canSend}
disabled={!message.trim() && attachedFiles.length === 0} className="h-9 w-9 shrink-0 md:h-10 md:w-10"
title="Enviar mensagem"
aria-label="Enviar mensagem"
> >
<Send className="w-4 h-4 md:w-5 md:h-5" /> <Send className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
<p className="hidden md:block text-xs text-muted-foreground text-center"> <p className="hidden text-center text-xs text-muted-foreground md:block">
Pressione Enter para enviar, Shift + Enter para nova linha Máx. {chatService.getMaxAttachments()} anexos Formatos: {chatService.getAllowedFileTypesLabel()} Pressione Enter para enviar, Shift + Enter para nova linha Máx. {chatService.getMaxAttachments()}{" "}
anexos Formatos: {chatService.getAllowedFileTypesLabel()}
</p> </p>
</div> </div>
</div> </div>
+384 -359
View File
@@ -1,5 +1,18 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import {
Search,
Folder,
FolderPlus,
MessageSquare,
MoreVertical,
Trash2,
Edit,
ChevronLeft,
ChevronRight,
ChevronDown,
Plus,
Inbox,
} from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -11,6 +24,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@@ -33,6 +47,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
interface ChatSidebarProps { interface ChatSidebarProps {
isCollapsed: boolean; isCollapsed: boolean;
@@ -42,7 +57,19 @@ interface ChatSidebarProps {
currentChatId?: string; currentChatId?: string;
} }
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => { const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<p className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{children}
</p>
);
export const ChatSidebar = ({
isCollapsed,
onToggleCollapse,
onNewChat,
onSelectChat,
currentChatId,
}: ChatSidebarProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
@@ -56,49 +83,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null); const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Estado carregado do banco de dados via chatService
const [chats, setChats] = useState<ChatRecord[]>([]); const [chats, setChats] = useState<ChatRecord[]>([]);
const [folders, setFolders] = useState<FolderRecord[]>([]); const [folders, setFolders] = useState<FolderRecord[]>([]);
/** Pastas fechadas por padrão; estado preservado ao recarregar lista */
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
// Carrega chats e pastas do banco de dados quando o componente monta
useEffect(() => { useEffect(() => {
loadChatsAndFolders(); loadChatsAndFolders();
// Escuta evento customizado para recarregar quando chat é atualizado
const handleChatUpdate = () => { const handleChatUpdate = () => {
loadChatsAndFolders(); loadChatsAndFolders();
}; };
window.addEventListener('chatUpdated', handleChatUpdate); window.addEventListener("chatUpdated", handleChatUpdate);
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
// Cleanup
return () => {
window.removeEventListener('chatUpdated', handleChatUpdate);
};
}, []); }, []);
const loadChatsAndFolders = async () => { const loadChatsAndFolders = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await chatService.getChatsAndFolders(); const data = await chatService.getChatsAndFolders();
console.log('Dados carregados:', data);
setChats(data.chats); setChats(data.chats);
setFolders(data.folders); setFolders(data.folders);
} catch (error: unknown) {
// Expande todas as pastas por padrão const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
setExpandedFolders(new Set(data.folders.map(f => f.id))); console.error("Erro ao carregar chats e pastas:", error);
} catch (error: any) {
console.error('Erro ao carregar chats e pastas:', error);
toast({ toast({
title: "Erro ao carregar dados", title: "Erro ao carregar dados",
description: error.message || "Não foi possível carregar chats e pastas.", description: message,
variant: "destructive", variant: "destructive",
}); });
// Define arrays vazios em caso de erro
setChats([]); setChats([]);
setFolders([]); setFolders([]);
} finally { } finally {
@@ -107,48 +121,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
}; };
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (newFolderName.trim()) { if (!newFolderName.trim()) return;
try { const name = newFolderName.trim();
await chatService.createFolder(newFolderName); try {
await loadChatsAndFolders(); await chatService.createFolder(name);
setNewFolderName(""); await loadChatsAndFolders();
setIsCreateFolderOpen(false); setNewFolderName("");
setIsCreateFolderOpen(false);
toast({ toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
title: "Pasta criada", } catch (error: unknown) {
description: `Pasta "${newFolderName}" criada com sucesso.`, const message = error instanceof Error ? error.message : "Não foi possível criar a pasta.";
}); toast({ title: "Erro ao criar pasta", description: message, variant: "destructive" });
} catch (error: any) {
console.error('Erro ao criar pasta:', error);
toast({
title: "Erro ao criar pasta",
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
variant: "destructive",
});
}
} }
}; };
const handleDeleteFolder = async () => { const handleDeleteFolder = async () => {
if (deletingFolder) { if (!deletingFolder) return;
try { try {
await chatService.deleteFolder(deletingFolder.id); await chatService.deleteFolder(deletingFolder.id);
await loadChatsAndFolders(); setExpandedFolders((prev) => {
setDeletingFolder(null); const next = new Set(prev);
setIsDeleteFolderOpen(false); next.delete(deletingFolder.id);
return next;
toast({ });
title: "Pasta excluída", await loadChatsAndFolders();
description: "A pasta foi excluída com sucesso.", setDeletingFolder(null);
}); setIsDeleteFolderOpen(false);
} catch (error: any) { toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
console.error('Erro ao excluir pasta:', error); } catch (error: unknown) {
toast({ const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
title: "Erro ao excluir pasta", toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
variant: "destructive",
});
}
} }
}; };
@@ -164,79 +166,52 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
}; };
const handleRenameFolder = async () => { const handleRenameFolder = async () => {
if (renamingFolder && renamedFolderName.trim()) { if (!renamingFolder || !renamedFolderName.trim()) return;
try { try {
await chatService.renameFolder(renamingFolder.id, renamedFolderName); await chatService.renameFolder(renamingFolder.id, renamedFolderName);
await loadChatsAndFolders(); await loadChatsAndFolders();
setRenamingFolder(null); setRenamingFolder(null);
setRenamedFolderName(""); setRenamedFolderName("");
setIsRenameFolderOpen(false); setIsRenameFolderOpen(false);
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
toast({ } catch (error: unknown) {
title: "Pasta renomeada", const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`, toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
});
} catch (error: any) {
console.error('Erro ao renomear pasta:', error);
toast({
title: "Erro ao renomear pasta",
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
variant: "destructive",
});
}
} }
}; };
const toggleFolder = (folderId: string) => { const toggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders); setExpandedFolders((prev) => {
if (newExpanded.has(folderId)) { const next = new Set(prev);
newExpanded.delete(folderId); if (next.has(folderId)) next.delete(folderId);
} else { else next.add(folderId);
newExpanded.add(folderId); return next;
} });
setExpandedFolders(newExpanded);
}; };
const moveToFolder = async (chatId: string, folderId: string) => { const moveToFolder = async (chatId: string, folderId: string) => {
try { try {
await chatService.moveChatToFolder(chatId, folderId); await chatService.moveChatToFolder(chatId, folderId);
await loadChatsAndFolders(); await loadChatsAndFolders();
const folder = folders.find((f) => f.id === folderId);
const folder = folders.find(f => f.id === folderId); toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
toast({ } catch (error: unknown) {
title: "Chat movido", const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
description: `Movido para a pasta "${folder?.name}".`, toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
});
} catch (error: any) {
console.error('Erro ao mover chat:', error);
toast({
title: "Erro ao mover chat",
description: error.message || "Não foi possível mover o chat. Tente novamente.",
variant: "destructive",
});
} }
}; };
const handleDeleteChat = async () => { const handleDeleteChat = async () => {
if (deletingChat) { if (!deletingChat) return;
try { try {
await chatService.deleteChat(deletingChat.id); await chatService.deleteChat(deletingChat.id);
await loadChatsAndFolders(); await loadChatsAndFolders();
setDeletingChat(null); setDeletingChat(null);
setIsDeleteChatOpen(false); setIsDeleteChatOpen(false);
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
toast({ } catch (error: unknown) {
title: "Chat excluído", const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
description: "A conversa foi excluída com sucesso.", toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
});
} catch (error: any) {
console.error('Erro ao excluir chat:', error);
toast({
title: "Erro ao excluir chat",
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
variant: "destructive",
});
}
} }
}; };
@@ -246,287 +221,318 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
}; };
const handleSelectChat = (chat: ChatRecord) => { const handleSelectChat = (chat: ChatRecord) => {
if (onSelectChat) { onSelectChat?.(chat);
onSelectChat(chat);
}
}; };
// Filtrar chats pela busca const filteredChats = chats.filter((chat) =>
const filteredChats = chats.filter((chat) => { chat.title.toLowerCase().includes(searchQuery.toLowerCase())
const searchLower = searchQuery.toLowerCase(); );
const titleMatch = chat.title.toLowerCase().includes(searchLower);
return titleMatch;
});
// Separar chats sem pasta (folder_id é null)
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null); const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
// Agrupar chats por pasta (quando folder_id === folder.id) const chatsByFolder = folders.reduce(
const chatsByFolder = folders.reduce((acc, folder) => { (acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id); acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
return acc; return acc;
}, {} as Record<string, ChatRecord[]>); },
{} as Record<string, ChatRecord[]>
);
const visibleFolders = folders.filter(
(folder) =>
(chatsByFolder[folder.id]?.length ?? 0) > 0 ||
!searchQuery.trim() ||
folder.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isCollapsed) { if (isCollapsed) {
return ( return (
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4"> <div className="flex h-full w-12 flex-col items-center border-r border-border bg-sidebar/50 py-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
title="Expandir conversas"
> >
<ChevronRight className="w-4 h-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
); );
} }
return ( return (
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col"> <div className="flex h-full w-80 max-w-80 min-w-0 shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar/40">
{/* Header with collapse button */} {/* Cabeçalho */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between"> <div className="flex min-w-0 shrink-0 items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">Conversas</h3> <h3 className="text-sm font-semibold text-foreground">Conversas</h3>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="h-7 w-7 text-muted-foreground hover:text-foreground" className="h-8 w-8 text-muted-foreground hover:text-foreground"
title="Recolher"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* Search */} {/* Busca + ações */}
<div className="px-4 pb-3 border-b border-border space-y-3"> <div className="min-w-0 shrink-0 space-y-2 border-b border-border px-3 py-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Pesquisar conversas..." placeholder="Pesquisar conversas..."
className="pl-9 bg-muted/50" className="h-9 bg-background/80 pl-9 text-sm"
/> />
</div> </div>
<Button className="w-full gap-2 cyber-glow" variant="default" onClick={onNewChat}> <div className="grid grid-cols-2 gap-2">
<Plus className="w-4 h-4" /> <Button className="h-9 gap-1.5 text-sm" variant="default" onClick={onNewChat}>
Novo Chat <Plus className="h-4 w-4" />
</Button> Novo chat
</Button>
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}> <Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full gap-2" size="sm"> <Button variant="outline" className="h-9 gap-1.5 text-sm">
<FolderPlus className="w-4 h-4" /> <FolderPlus className="h-4 w-4" />
Nova Pasta Pasta
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="glass-effect bg-card border-border z-50"> <DialogContent className="border-border bg-card">
<DialogHeader> <DialogHeader>
<DialogTitle>Criar Nova Pasta</DialogTitle> <DialogTitle>Nova pasta</DialogTitle>
<DialogDescription> <DialogDescription>Organize suas conversas em pastas.</DialogDescription>
Organize suas conversas em pastas personalizadas. </DialogHeader>
</DialogDescription> <div className="space-y-2 py-2">
</DialogHeader> <Label htmlFor="folder-name">Nome</Label>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="folder-name">Nome da Pasta</Label>
<Input <Input
id="folder-name" id="folder-name"
value={newFolderName} value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)} onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ex: Projetos, Estudos..." placeholder="Ex: Projetos, Clientes..."
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()} onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/> />
</div> </div>
</div> <DialogFooter>
<DialogFooter> <Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}> Cancelar
Cancelar </Button>
</Button> <Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}> Criar
Criar Pasta </Button>
</Button> </DialogFooter>
</DialogFooter> </DialogContent>
</DialogContent> </Dialog>
</Dialog> </div>
{/* Rename Folder Dialog */}
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
<DialogContent className="glass-effect bg-card border-border z-50">
<DialogHeader>
<DialogTitle>Renomear Pasta</DialogTitle>
<DialogDescription>
Digite o novo nome para a pasta "{renamingFolder?.name}".
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="rename-folder-name">Novo Nome</Label>
<Input
id="rename-folder-name"
value={renamedFolderName}
onChange={(e) => setRenamedFolderName(e.target.value)}
placeholder="Ex: Projetos, Estudos..."
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
Cancelar
</Button>
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
Renomear
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Folder Alert */}
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50">
<AlertDialogHeader>
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteFolder} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
{/* Conversations List */} <Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
<ScrollArea className="flex-1"> <DialogContent className="border-border bg-card">
<div className="p-2 space-y-1"> <DialogHeader>
<DialogTitle>Renomear pasta</DialogTitle>
<DialogDescription>
Novo nome para &quot;{renamingFolder?.name}&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="rename-folder-name">Nome</Label>
<Input
id="rename-folder-name"
value={renamedFolderName}
onChange={(e) => setRenamedFolderName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
Cancelar
</Button>
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
Salvar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
<AlertDialogContent className="border-border bg-card">
<AlertDialogHeader>
<AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
<AlertDialogDescription>
A pasta &quot;{deletingFolder?.name}&quot; será excluída. As conversas dentro dela
ficarão sem pasta.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteFolder}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Lista */}
<ScrollArea className="min-h-0 w-full min-w-0 flex-1 overflow-hidden">
<div className="box-border w-full max-w-full min-w-0 space-y-1 overflow-x-hidden p-2">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" /> <div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
</div> </div>
) : ( ) : (
<> <>
{/* Folders */} {visibleFolders.length > 0 && (
{folders.map((folder) => ( <section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
<div key={folder.id} className="space-y-1"> {visibleFolders.map((folder) => {
<div className="flex items-center gap-1"> const folderChats = chatsByFolder[folder.id] ?? [];
<button const isExpanded = expandedFolders.has(folder.id);
onClick={() => toggleFolder(folder.id)}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
<Folder className="w-4 h-4 text-white" />
</div>
<span className="font-medium text-sm flex-1 text-left">
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{chatsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu> return (
<DropdownMenuTrigger asChild> <div key={folder.id} className="min-w-0">
<Button <div className="flex min-w-0 items-center">
variant="ghost" <button
size="icon" type="button"
className="h-8 w-8 text-muted-foreground hover:text-foreground" onClick={() => toggleFolder(folder.id)}
> className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent/50"
<MoreVertical className="w-3 h-3" /> aria-expanded={isExpanded}
</Button> >
</DropdownMenuTrigger> <ChevronDown
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50"> className={cn(
<DropdownMenuItem "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
className="gap-2" !isExpanded && "-rotate-90"
onClick={() => openRenameFolder(folder)} )}
> />
<Edit className="w-3 h-3" /> <Folder className="h-4 w-4 shrink-0 text-primary/90" />
Renomear Pasta <span className="min-w-0 flex-1 truncate text-sm font-medium">
</DropdownMenuItem> {folder.name}
<DropdownMenuItem </span>
className="gap-2 text-destructive" <span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
onClick={() => openDeleteFolder(folder)} {folderChats.length}
> </span>
<Trash2 className="w-3 h-3" /> </button>
Excluir Pasta <DropdownMenu>
</DropdownMenuItem> <DropdownMenuTrigger asChild>
</DropdownMenuContent> <Button
</DropdownMenu> variant="ghost"
</div> size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
<DropdownMenuItem className="gap-2" onClick={() => openRenameFolder(folder)}>
<Edit className="h-3.5 w-3.5" />
Renomear
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => openDeleteFolder(folder)}
>
<Trash2 className="h-3.5 w-3.5" />
Excluir pasta
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && ( {isExpanded && (
<div className="ml-6 space-y-1"> <div className="min-w-0 space-y-0.5 py-0.5 pl-5">
{chatsByFolder[folder.id].map((chat) => ( {folderChats.length > 0 ? (
<ChatItem folderChats.map((chat) => (
key={chat.id} <ChatItem
chat={chat} key={chat.id}
isSelected={currentChatId === chat.id} chat={chat}
onSelect={() => handleSelectChat(chat)} isSelected={currentChatId === chat.id}
folders={folders} onSelect={() => handleSelectChat(chat)}
onMoveToFolder={moveToFolder} folders={folders}
onDelete={() => openDeleteChat(chat)} onMoveToFolder={moveToFolder}
/> onDelete={() => openDeleteChat(chat)}
))} nested
</div> />
)} ))
</div> ) : (
))} <p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
)}
</div>
)}
</div>
);
})}
</section>
)}
{/* Chats without folder */}
{chatsWithoutFolder.length > 0 && ( {chatsWithoutFolder.length > 0 && (
<div className="space-y-1"> <section
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider"> className={cn(
Sem Pasta "w-full min-w-0 max-w-full overflow-hidden",
visibleFolders.length > 0 && "mt-3 border-t border-border/40 pt-3"
)}
>
<SectionLabel>Conversas</SectionLabel>
<div className="min-w-0 space-y-0.5">
{chatsWithoutFolder.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div> </div>
{chatsWithoutFolder.map((chat) => ( </section>
<ChatItem )}
key={chat.id}
chat={chat} {!isLoading && chats.length === 0 && (
isSelected={currentChatId === chat.id} <div className="flex flex-col items-center justify-center px-4 py-12 text-center">
onSelect={() => handleSelectChat(chat)} <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
folders={folders} <Inbox className="h-6 w-6 text-muted-foreground" />
onMoveToFolder={moveToFolder} </div>
onDelete={() => openDeleteChat(chat)} <p className="text-sm font-medium text-foreground">Nenhuma conversa</p>
/> <p className="mt-1 text-xs text-muted-foreground">
))} Crie um novo chat para começar
</p>
</div> </div>
)} )}
{/* Empty state */} {!isLoading &&
{!isLoading && chats.length === 0 && ( chats.length > 0 &&
<div className="flex flex-col items-center justify-center p-8 text-center"> filteredChats.length === 0 &&
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" /> searchQuery.trim() && (
<p className="text-sm text-muted-foreground"> <p className="px-2 py-6 text-center text-sm text-muted-foreground">
Nenhuma conversa ainda Nenhum resultado para &quot;{searchQuery}&quot;
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> )}
Clique em "Novo Chat" para começar
</p>
</div>
)}
</> </>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
{/* Delete Chat Alert */}
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}> <AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50"> <AlertDialogContent className="border-border bg-card">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle> <AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída. &quot;{deletingChat?.title}&quot; será excluída permanentemente.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> <AlertDialogAction
onClick={handleDeleteChat}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Excluir Excluir
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -543,6 +549,7 @@ interface ChatItemProps {
folders: FolderRecord[]; folders: FolderRecord[];
onMoveToFolder: (chatId: string, folderId: string) => void; onMoveToFolder: (chatId: string, folderId: string) => void;
onDelete: () => void; onDelete: () => void;
nested?: boolean;
} }
const ChatItem = ({ const ChatItem = ({
@@ -552,8 +559,8 @@ const ChatItem = ({
folders, folders,
onMoveToFolder, onMoveToFolder,
onDelete, onDelete,
nested = false,
}: ChatItemProps) => { }: ChatItemProps) => {
// Formata timestamp relativo
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), { const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
addSuffix: true, addSuffix: true,
locale: ptBR, locale: ptBR,
@@ -561,19 +568,34 @@ const ChatItem = ({
return ( return (
<div <div
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${ role="button"
isSelected tabIndex={0}
? "bg-sidebar-accent cyber-border"
: "hover:bg-muted/50"
}`}
onClick={onSelect} onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
className={cn(
"group flex w-full max-w-full min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 transition-colors",
nested && "py-1",
isSelected
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "hover:bg-sidebar-accent/50"
)}
> >
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" /> <MessageSquare
<div className="flex-1 min-w-0 max-w-[180px]"> className={cn(
<p className="text-sm font-medium truncate" title={chat.title}> "h-4 w-4 shrink-0",
isSelected ? "text-primary" : "text-muted-foreground"
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-snug" title={chat.title}>
{chat.title} {chat.title}
</p> </p>
<p className="text-xs text-muted-foreground truncate" title={timeAgo}> <p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
{timeAgo} {timeAgo}
</p> </p>
</div> </div>
@@ -583,29 +605,32 @@ const ChatItem = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" className="h-7 w-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="Opções da conversa"
> >
<MoreVertical className="w-3 h-3" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50"> <DropdownMenuContent align="end" className="border-border bg-popover">
{/* Opções para mover para pastas */} {folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
{folders.filter(f => f.id !== chat.folder_id).map((folder) => ( <>
<DropdownMenuItem {folders
key={folder.id} .filter((f) => f.id !== chat.folder_id)
className="gap-2" .map((folder) => (
onClick={() => onMoveToFolder(chat.id, folder.id)} <DropdownMenuItem
> key={folder.id}
<Folder className="w-3 h-3" /> className="gap-2"
Mover para {folder.name} onClick={() => onMoveToFolder(chat.id, folder.id)}
</DropdownMenuItem> >
))} <Folder className="h-3.5 w-3.5" />
Mover para {folder.name}
<DropdownMenuItem </DropdownMenuItem>
className="gap-2 text-destructive" ))}
onClick={onDelete} <DropdownMenuSeparator />
> </>
<Trash2 className="w-3 h-3" /> )}
<DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
Excluir Excluir
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+42 -48
View File
@@ -3,6 +3,13 @@ import { ChatHeader } from "@/components/ChatHeader";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput"; import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar"; import { ChatSidebar } from "./ChatSidebar";
import { ChatWelcome } from "./ChatWelcome";
import {
createWelcomeMessage,
isWelcomeMessage,
withoutWelcomeMessages,
} from "./welcome-message";
import type { Message } from "./ChatView.types";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { chatService, ChatRecord, MessageRecord } from "@/services/chat"; import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -11,18 +18,6 @@ import { getModelId } from "@/config/models";
// Texto padrão da personalidade (usado para novos chats) // Texto padrão da personalidade (usado para novos chats)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo."; const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
model?: string;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}
export const ChatView = () => { export const ChatView = () => {
const { toast } = useToast(); const { toast } = useToast();
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false); const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
@@ -31,16 +26,17 @@ export const ChatView = () => {
const [currentChatId, setCurrentChatId] = useState("0"); const [currentChatId, setCurrentChatId] = useState("0");
// Inicia com texto padrão - será atualizado ao carregar chat existente // Inicia com texto padrão - será atualizado ao carregar chat existente
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT); const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
const [messages, setMessages] = useState<Message[]>([ const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: "GPT-4o",
},
]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [inputDraft, setInputDraft] = useState<{
text: string;
at: number;
} | null>(null);
const handleSelectSuggestion = (prompt: string) => {
setInputDraft({ text: prompt, at: Date.now() });
};
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API // NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
// quando enviadas via handleSendMessage // quando enviadas via handleSendMessage
@@ -82,14 +78,7 @@ export const ChatView = () => {
// Reseta a personalidade para o texto padrão // Reseta a personalidade para o texto padrão
setSystemPrompt(DEFAULT_SYSTEM_PROMPT); setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
setMessages([ setMessages([createWelcomeMessage()]);
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: selectedModel,
},
]);
// Dispara evento para atualizar a sidebar // Dispara evento para atualizar a sidebar
window.dispatchEvent(new Event('chatUpdated')); window.dispatchEvent(new Event('chatUpdated'));
@@ -136,14 +125,7 @@ export const ChatView = () => {
}); });
// Inicia com mensagem padrão em caso de erro // Inicia com mensagem padrão em caso de erro
setMessages([ setMessages([createWelcomeMessage()]);
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: selectedModel,
},
]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -162,7 +144,7 @@ export const ChatView = () => {
})), })),
}; };
setMessages((prev) => [...prev, newMessage]); setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
setIsLoading(true); setIsLoading(true);
try { try {
@@ -234,7 +216,7 @@ export const ChatView = () => {
return ( return (
<div className="flex h-full w-full pb-16 md:pb-0"> <div className="flex h-full w-full pb-16 md:pb-0">
{/* Chat Sidebar - Hidden on mobile */} {/* Chat Sidebar - Hidden on mobile */}
<div className="hidden md:block"> <div className="hidden w-80 max-w-80 shrink-0 min-w-0 overflow-hidden md:block">
<ChatSidebar <ChatSidebar
isCollapsed={isChatSidebarCollapsed} isCollapsed={isChatSidebarCollapsed}
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)} onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
@@ -256,15 +238,22 @@ export const ChatView = () => {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto px-2 md:px-4"> <div className="max-w-4xl mx-auto px-2 md:px-4">
{messages.map((message) => ( {messages.map((message) =>
<ChatMessage isWelcomeMessage(message) ? (
key={message.id} <ChatWelcome
role={message.role} key={message.id}
content={message.content} onSelectSuggestion={handleSelectSuggestion}
model={message.model} />
attachments={message.attachments} ) : (
/> <ChatMessage
))} key={message.id}
role={message.role}
content={message.content}
model={message.model}
attachments={message.attachments}
/>
)
)}
{isLoading && ( {isLoading && (
<div className="flex gap-4 py-6 px-6 bg-muted/30"> <div className="flex gap-4 py-6 px-6 bg-muted/30">
@@ -281,7 +270,12 @@ export const ChatView = () => {
</div> </div>
</ScrollArea> </ScrollArea>
<ChatInput onSendMessage={handleSendMessage} /> <ChatInput
onSendMessage={handleSendMessage}
draftMessage={inputDraft?.text}
draftTrigger={inputDraft?.at}
onDraftApplied={() => setInputDraft(null)}
/>
</div> </div>
</div> </div>
); );
+11
View File
@@ -0,0 +1,11 @@
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
model?: string;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}
+85
View File
@@ -0,0 +1,85 @@
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import {
CHAT_STARTER_SUGGESTIONS,
COMMANDER_REVEAL_COLORS,
} from "./welcome-message";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
interface ChatWelcomeProps {
className?: string;
onSelectSuggestion?: (prompt: string) => void;
}
export function ChatWelcome({ className, onSelectSuggestion }: ChatWelcomeProps) {
return (
<div
className={cn(
"flex min-h-[min(420px,55vh)] flex-col items-center justify-center px-4 py-10 text-center md:px-6 md:py-16",
className
)}
>
<div className="mb-2 h-1 w-12 rounded-full bg-primary/40" aria-hidden />
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
<DiaTextReveal
text="HGTX Commander"
colors={COMMANDER_REVEAL_COLORS}
textColor="hsl(var(--foreground))"
duration={1.35}
className="text-inherit"
/>
</h2>
<p className="max-w-md text-base leading-relaxed text-muted-foreground md:text-lg">
<DiaTextReveal
text="Como posso ajudá-lo hoje?"
colors={COMMANDER_REVEAL_COLORS}
textColor="hsl(var(--muted-foreground))"
duration={1.15}
delay={0.4}
className="text-inherit"
/>
</p>
{onSelectSuggestion && (
<div className="mt-8 w-full max-w-2xl animate-fade-in space-y-3">
<div className="flex items-center justify-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-primary/80" aria-hidden />
<AnimatedShinyText
className="mx-0 max-w-none text-xs font-semibold uppercase tracking-wider text-muted-foreground"
shimmerWidth={120}
>
Ideias para começar
</AnimatedShinyText>
</div>
<div className="flex flex-wrap items-stretch justify-center gap-2">
{CHAT_STARTER_SUGGESTIONS.map((suggestion) => (
<button
key={suggestion.id}
type="button"
onClick={() => onSelectSuggestion(suggestion.prompt)}
title={suggestion.prompt}
className={cn(
"max-w-full rounded-full border border-border/80 bg-background/70 px-4 py-2.5 text-left text-sm font-medium text-foreground",
"shadow-sm transition-all duration-200",
"hover:border-primary/40 hover:bg-sidebar-accent/60 hover:shadow-md",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"active:scale-[0.98]"
)}
>
<span className="line-clamp-2">{suggestion.label}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground/80">
Toque em uma sugestão para preencher a mensagem você pode editar antes de enviar.
</p>
</div>
)}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Message } from "./ChatView.types";
export const CHAT_WELCOME_MESSAGE_ID = "welcome";
/** Paleta alinhada ao azul HGTX Commander */
export const COMMANDER_REVEAL_COLORS = [
"#003366",
"#0055a4",
"#0066b3",
"#4d9de0",
"#a8c8f0",
];
export function createWelcomeMessage(): Message {
return {
id: CHAT_WELCOME_MESSAGE_ID,
role: "assistant",
content: "",
};
}
export function isWelcomeMessage(message: Message): boolean {
return message.id === CHAT_WELCOME_MESSAGE_ID;
}
export function withoutWelcomeMessages(messages: Message[]): Message[] {
return messages.filter((m) => !isWelcomeMessage(m));
}
export type ChatStarterSuggestion = {
id: string;
label: string;
prompt: string;
};
export const CHAT_STARTER_SUGGESTIONS: ChatStarterSuggestion[] = [
{
id: "executive-summary",
label: "Resumo com pendências",
prompt:
"Vou colar um texto ou documento abaixo. Produza um resumo executivo em tópicos, separe fatos de interpretações e liste pendências ou riscos que exijam decisão.",
},
{
id: "meeting-minutes",
label: "Ata da reunião",
prompt:
"Com base no conteúdo que enviarei, redija uma ata executiva: contexto em uma linha, decisões tomadas, responsáveis, prazos e pontos que ficaram em aberto.",
},
{
id: "audio-structure",
label: "Organizar transcrição",
prompt:
"Tenho uma transcrição de áudio para colar em seguida. Organize por tópicos cronológicos, destaque compromissos assumidos e sugira follow-ups que não foram fechados na conversa.",
},
{
id: "structured-reasoning",
label: "Linha de raciocínio",
prompt:
"Preciso estruturar um raciocínio sobre o tema que descreverei. Monte hipóteses, argumentos a favor e contra, e indique quais informações ainda faltam para fechar uma conclusão sólida.",
},
{
id: "compare-options",
label: "Comparar alternativas",
prompt:
"Vou descrever duas ou mais alternativas em seguida. Compare critérios objetivos, trade-offs e riscos de cada uma, e indique em que cenário cada opção faz mais sentido — sem favorecer uma resposta só pelo tom.",
},
{
id: "professional-draft",
label: "Comunicado profissional",
prompt:
"Com os pontos que enviarei abaixo, redija um e-mail ou comunicado interno: tom profissional e direto, assunto sugerido, corpo estruturado e uma ação clara no encerramento.",
},
];
@@ -0,0 +1,449 @@
import { useMemo, useState, type ChangeEvent } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Copy, Download, FileText, Loader2, Plus, Upload, X } from "lucide-react";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { toast } from "sonner";
import { commanderCsvService, type CommanderCsvMode } from "@/services/commanderCsv";
type HeaderItem = {
original: string;
slug: string;
};
const toSlug = (value: string) =>
value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
export function CommanderCsvView() {
const [mode, setMode] = useState<CommanderCsvMode>("pdf");
const [headerInput, setHeaderInput] = useState("");
const [headers, setHeaders] = useState<HeaderItem[]>([]);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isHealthLoading, setIsHealthLoading] = useState(false);
const [healthStatus, setHealthStatus] = useState<"idle" | "ok" | "error">("idle");
const [responseData, setResponseData] = useState<unknown>(null);
const [isClearHeadersOpen, setIsClearHeadersOpen] = useState(false);
const [lastSentAt, setLastSentAt] = useState<Date | null>(null);
const [lastSentMode, setLastSentMode] = useState<CommanderCsvMode | null>(null);
const headersCsv = useMemo(() => headers.map((header) => header.slug).join(","), [headers]);
const addHeader = () => {
const original = headerInput.trim();
if (!original) return;
const slug = toSlug(original);
if (!slug) {
toast.error("Cabecalho invalido. Digite letras ou numeros.");
return;
}
if (headers.some((header) => header.slug === slug)) {
toast.error("Esse cabecalho ja foi adicionado.");
return;
}
setHeaders((prev) => [...prev, { original, slug }]);
setHeaderInput("");
};
const removeHeader = (slug: string) => {
setHeaders((prev) => prev.filter((header) => header.slug !== slug));
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] ?? null;
if (!file) return;
const fileName = file.name.toLowerCase();
if (!fileName.endsWith(".pdf")) {
toast.error("Envie apenas arquivo PDF.");
return;
}
setSelectedFile(file);
};
const runHealthcheck = async () => {
setIsHealthLoading(true);
try {
await commanderCsvService.healthcheck();
setHealthStatus("ok");
toast.success("API online.");
} catch (error: unknown) {
setHealthStatus("error");
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Falha no healthcheck.";
toast.error(message);
} finally {
setIsHealthLoading(false);
}
};
const submit = async () => {
if (headers.length === 0) {
toast.error("Adicione pelo menos um cabecalho.");
return;
}
if (mode === "pdf" && !selectedFile) {
toast.error("Selecione um PDF para enviar.");
return;
}
if (mode === "text" && !content.trim()) {
toast.error("Preencha o conteudo de texto.");
return;
}
setIsSubmitting(true);
try {
const result = mode === "pdf"
? await commanderCsvService.pdf2csv(selectedFile as File, headersCsv)
: await commanderCsvService.txt2csv(content.trim(), headersCsv);
setResponseData(result);
setLastSentAt(new Date());
setLastSentMode(mode);
toast.success("Processamento concluido.");
} catch (error: unknown) {
const message = error && typeof error === "object" && "message" in error
? (error as { message: string }).message
: "Erro ao enviar dados para API.";
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const responseContent = useMemo(() => {
if (responseData && typeof responseData === "object" && "content" in responseData) {
const content = (responseData as { content?: unknown }).content;
if (typeof content === "string") return content;
}
return null;
}, [responseData]);
const copyResponse = async () => {
const textToCopy = responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "");
if (!textToCopy) {
toast.error("Nao ha resposta para copiar.");
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Resposta copiada.");
} catch {
toast.error("Falha ao copiar resposta.");
}
};
const downloadCsv = () => {
if (!responseContent) {
toast.error("Nao ha CSV para baixar.");
return;
}
const utf8Bom = "\uFEFF";
const blob = new Blob([utf8Bom, responseContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `commander-csv-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.csv`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.success("Download do CSV iniciado.");
};
const sendBlockReason = useMemo(() => {
if (headers.length === 0) return "Adicione pelo menos um cabecalho.";
if (mode === "pdf" && !selectedFile) return "Selecione um PDF para envio.";
if (mode === "text" && !content.trim()) return "Preencha o conteudo de texto.";
return null;
}, [headers.length, mode, selectedFile, content]);
return (
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-xl md:text-3xl font-bold">Commander CSV</h1>
<p className="text-sm text-muted-foreground">
Configure os cabecalhos, escolha o modo de envio e gere o CSV com mais controle.
</p>
</div>
<div className="flex items-center gap-2">
<div
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold ${
healthStatus === "ok"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: healthStatus === "error"
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-border bg-muted/40 text-muted-foreground"
}`}
>
<span
className={`h-2 w-2 rounded-full ${
healthStatus === "ok"
? "bg-emerald-500 animate-online-pulse"
: healthStatus === "error"
? "bg-destructive"
: "bg-muted-foreground/70"
}`}
/>
{healthStatus === "ok" ? "API online" : healthStatus === "error" ? "API indisponivel" : "Status da API"}
</div>
<Button variant="outline" onClick={runHealthcheck} disabled={isHealthLoading}>
{isHealthLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Healthcheck
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="mx-auto w-full max-w-6xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Cabecalhos</CardTitle>
<CardDescription>
Defina os campos do CSV. O sistema gera slug automaticamente em minusculo com underline.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Input
value={headerInput}
onChange={(event) => setHeaderInput(event.target.value)}
placeholder="Ex: Data de Aniversario"
className="min-w-[220px] flex-1"
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addHeader();
}
}}
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsClearHeadersOpen(true)}
disabled={headers.length === 0}
>
Limpar todos
</Button>
<Button type="button" onClick={addHeader} disabled={!headerInput.trim()}>
<Plus className="w-4 h-4 mr-1" />
Adicionar
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 rounded-xl border bg-muted/20 p-3">
{headers.length === 0 ? (
<p className="text-sm text-muted-foreground">
Adicione seu primeiro cabecalho para habilitar o envio.
</p>
) : (
headers.map((header) => (
<div
key={header.slug}
className="group inline-flex items-center gap-2 rounded-full border border-primary/20 bg-primary/5 px-3 py-1.5 shadow-sm transition-colors hover:border-primary/40 hover:bg-primary/10"
>
<p className="text-xs font-semibold text-foreground">{header.slug}</p>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeHeader(header.slug)}
title="Remover cabecalho"
className="h-5 w-5 rounded-full text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
))
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Envio</CardTitle>
<CardDescription>
Alterne entre PDF ou Texto com os mesmos cabecalhos configurados.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={mode}
onValueChange={(value) => setMode(value as CommanderCsvMode)}
className="grid gap-3 sm:grid-cols-2"
>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="pdf" id="csv-mode-pdf" />
<div>
<p className="text-sm font-medium">Modo PDF</p>
<p className="text-xs text-muted-foreground">Upload de arquivo PDF + headers</p>
</div>
</label>
<label className="flex cursor-pointer items-center gap-3 rounded-md border p-4">
<RadioGroupItem value="text" id="csv-mode-text" />
<div>
<p className="text-sm font-medium">Modo Texto</p>
<p className="text-xs text-muted-foreground">Conteudo em texto + headers</p>
</div>
</label>
</RadioGroup>
{mode === "pdf" ? (
<div className="space-y-3">
<Label htmlFor="csv-pdf-input">Arquivo PDF</Label>
<input
id="csv-pdf-input"
type="file"
accept=".pdf,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
<label
htmlFor="csv-pdf-input"
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors hover:border-primary/50"
>
<Upload className="h-7 w-7 text-primary" />
<div>
<p className="text-sm font-medium">
{selectedFile ? selectedFile.name : "Clique para anexar um PDF"}
</p>
<p className="text-xs text-muted-foreground">
{selectedFile ? `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB` : "Somente .pdf"}
</p>
</div>
</label>
</div>
) : (
<div className="space-y-3">
<Label htmlFor="csv-text-input">Conteudo para converter</Label>
<Textarea
id="csv-text-input"
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="Cole aqui os dados em texto que serao convertidos para CSV..."
rows={10}
className="resize-y min-h-[220px]"
/>
</div>
)}
<div className="sticky bottom-0 z-10 -mx-6 rounded-b-lg border-t bg-card/95 px-6 py-4 backdrop-blur">
{isSubmitting ? (
<div className="mb-3 flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processando arquivo e gerando CSV...
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{sendBlockReason ?? "Tudo certo. Pronto para enviar."}
</p>
<InteractiveHoverButton
type="button"
onClick={submit}
disabled={isSubmitting || Boolean(sendBlockReason)}
className="min-w-44 disabled:opacity-50 disabled:pointer-events-none"
>
{isSubmitting ? "Enviando..." : "Enviar para API"}
</InteractiveHoverButton>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="w-5 h-5" />
Resposta da API
</CardTitle>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={copyResponse}>
<Copy className="w-4 h-4 mr-2" />
Copiar
</Button>
<Button type="button" variant="outline" size="sm" onClick={downloadCsv} disabled={!responseContent}>
<Download className="w-4 h-4 mr-2" />
Baixar CSV
</Button>
</div>
</div>
{lastSentAt ? (
<p className="text-xs text-muted-foreground">
Ultimo envio: {lastSentAt.toLocaleTimeString("pt-BR")} ({lastSentMode === "pdf" ? "PDF" : "Texto"})
</p>
) : null}
</CardHeader>
<CardContent>
<Textarea
value={responseContent ?? (responseData ? JSON.stringify(responseData, null, 2) : "Nenhuma resposta ainda.")}
readOnly
className="min-h-[62vh] font-mono text-xs leading-relaxed resize-y"
/>
</CardContent>
</Card>
</div>
</div>
<AlertDialog open={isClearHeadersOpen} onOpenChange={setIsClearHeadersOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Limpar todos os cabecalhos?</AlertDialogTitle>
<AlertDialogDescription>
Essa acao remove todos os cabecalhos adicionados nesta tela.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setHeaders([]);
setIsClearHeadersOpen(false);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Limpar tudo
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link, useLocation } from "react-router-dom";
import { Building2, Loader2, MapPinOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import {
getEstabelecimentoCodigoFromTransfer,
lookupUnidadeByEstabelecimento,
} from "@/services/fechamento/unidadeContext";
type GatePhase =
| "loading"
| "ready"
| "sem_codigo_transfer"
| "unidade_nao_cadastrada"
| "usuario_outra_unidade";
type UnidadeGateProps = {
children: ReactNode;
};
function isConfiguracoesPath(pathname: string): boolean {
return pathname.includes("configuracoes");
}
export function UnidadeGate({ children }: UnidadeGateProps) {
const { me, papel } = useAuthAccess();
const location = useLocation();
const [phase, setPhase] = useState<GatePhase>("loading");
const [codigoTransfer, setCodigoTransfer] = useState("");
useEffect(() => {
let mounted = true;
const run = async () => {
const codigo = getEstabelecimentoCodigoFromTransfer();
if (!mounted) return;
setCodigoTransfer(codigo);
if (!codigo) {
setPhase("sem_codigo_transfer");
return;
}
try {
const row = await lookupUnidadeByEstabelecimento(codigo);
if (!mounted) return;
if (!row) {
setPhase("unidade_nao_cadastrada");
return;
}
if (papel !== "admin" && papel !== "supervisor" && me?.unidadeId && me.unidadeId !== row.id) {
setPhase("usuario_outra_unidade");
return;
}
setPhase("ready");
} catch {
if (!mounted) return;
setPhase("unidade_nao_cadastrada");
}
};
void run();
return () => {
mounted = false;
};
}, [me?.unidadeId, location.pathname, papel]);
const isAdmin = papel === "admin";
const isStaff = papel === "admin" || papel === "supervisor";
const onConfiguracoes = isConfiguracoesPath(location.pathname);
if (phase === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Verificando unidade do estabelecimento...</p>
</div>
</div>
);
}
if (phase === "ready") {
return <>{children}</>;
}
if (phase === "unidade_nao_cadastrada" && isStaff && onConfiguracoes) {
return <>{children}</>;
}
if (phase === "sem_codigo_transfer") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<MapPinOff className="h-10 w-10 text-muted-foreground" />
</div>
<CardTitle>Estabelecimento não identificado</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
HGTX Intelligence Score a partir do Commander com o estabelecimento carregado no transfer.
</p>
</CardContent>
</Card>
</div>
);
}
if (phase === "unidade_nao_cadastrada") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<Building2 className="h-10 w-10 text-primary" />
</div>
<CardTitle>Unidade ainda não cadastrada</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
Não existe unidade no Commander para o estabelecimento{" "}
<span className="font-mono text-foreground">{codigoTransfer || "—"}</span>.
</p>
{isAdmin ? (
<>
<p className="text-sm text-muted-foreground">
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
</p>
<Button asChild>
<Link to="/intelligence-score/configuracoes#unidade">Ir para Configurações</Link>
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">
Peça a um administrador para cadastrar a unidade deste estabelecimento no Commander. Informe o
código: <span className="font-mono text-foreground">{codigoTransfer}</span>
</p>
)}
</CardContent>
</Card>
</div>
);
}
if (phase === "usuario_outra_unidade") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle>Estabelecimento diferente do seu cadastro</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
Você está vinculado a outra unidade no Commander do que o estabelecimento aberto neste contexto
(código <span className="font-mono text-foreground">{codigoTransfer}</span>).
</p>
<p className="text-sm">
Abra o módulo com o estabelecimento correspondente ao seu usuário ou solicite ajuste ao
administrador.
</p>
</CardContent>
</Card>
</div>
);
}
return <>{children}</>;
}
@@ -96,9 +96,9 @@ interface ChatMessage {
tipo_resposta?: "chat" | "agente" | null; tipo_resposta?: "chat" | "agente" | null;
} }
const LS_READER_FONT = "codex-parecer-reader-font-step"; const LS_READER_FONT = "commander-parecer-reader-font-step";
const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width"; const LS_READER_WIDTH = "commander-parecer-reader-comfortable-width";
const LS_MAIN_COMFORTABLE = "codex-parecer-main-comfortable-width"; const LS_MAIN_COMFORTABLE = "commander-parecer-main-comfortable-width";
/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */ /** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const; const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
@@ -588,7 +588,7 @@ export function ParecerJuridicoDetailView() {
.then((res) => { .then((res) => {
toast.success(res.message); toast.success(res.message);
setDeleteOpen(false); setDeleteOpen(false);
navigate("/codex/parecer-juridico"); navigate("/commander/parecer-juridico");
}) })
.catch((e) => { .catch((e) => {
toast.error(e?.message ?? "Erro ao excluir parecer."); toast.error(e?.message ?? "Erro ao excluir parecer.");
@@ -672,7 +672,7 @@ export function ParecerJuridicoDetailView() {
if (!id?.trim()) { if (!id?.trim()) {
return ( return (
<div className="flex flex-col h-full bg-background p-6"> <div className="flex flex-col h-full bg-background p-6">
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2"> <Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Voltar Voltar
</Button> </Button>
@@ -697,7 +697,7 @@ export function ParecerJuridicoDetailView() {
if (error && !parecer && !hasPreview) { if (error && !parecer && !hasPreview) {
return ( return (
<div className="flex flex-col h-full bg-background p-6"> <div className="flex flex-col h-full bg-background p-6">
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2"> <Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Voltar Voltar
</Button> </Button>
@@ -721,7 +721,7 @@ export function ParecerJuridicoDetailView() {
<div className="flex flex-col h-full bg-background pb-16 md:pb-0"> <div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border"> <header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0"> <Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1> <h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
@@ -119,7 +119,7 @@ export function ParecerJuridicoFormView() {
.then((data) => { .then((data) => {
setIsGenerating(false); setIsGenerating(false);
playSuccessSound(); playSuccessSound();
navigate(`/codex/parecer-juridico/${data.id}`, { navigate(`/commander/parecer-juridico/${data.id}`, {
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" }, state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
}); });
}) })
@@ -136,11 +136,11 @@ export function ParecerJuridicoFormView() {
{isGenerating && ( {isGenerating && (
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." /> <ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
)} )}
<div className="flex flex-col h-full bg-background pb-16 md:pb-0"> <div className="flex flex-col h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border"> <div className="shrink-0 p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0"> <Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2"> <h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
@@ -151,8 +151,8 @@ export function ParecerJuridicoFormView() {
</div> </div>
</div> </div>
<div className="flex-1 overflow-auto p-3 md:p-6"> <div className="flex-1 min-h-0 overflow-y-auto overscroll-y-contain p-3 md:p-6">
<div className="max-w-4xl mx-auto flex flex-col gap-6"> <div className="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1"> <Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
<span>Título do parecer</span> <span>Título do parecer</span>
@@ -105,7 +105,7 @@ export const ParecerJuridicoView = () => {
}; };
const handleEditar = (item: ParecerListItem) => { const handleEditar = (item: ParecerListItem) => {
navigate(`/codex/parecer-juridico/${item.id}`); navigate(`/commander/parecer-juridico/${item.id}`);
}; };
return ( return (
@@ -117,7 +117,7 @@ export const ParecerJuridicoView = () => {
Parecer Jurídico Parecer Jurídico
</h1> </h1>
<div className="flex gap-2 w-full md:w-auto"> <div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/codex/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm"> <Button onClick={() => navigate("/commander/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" /> <Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo Parecer</span> <span className="hidden sm:inline">Novo Parecer</span>
<span className="sm:hidden">Novo</span> <span className="sm:hidden">Novo</span>
@@ -0,0 +1,92 @@
import { Sparkles } from "lucide-react";
import { useTheme } from "next-themes";
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
import { cn } from "@/lib/utils";
type PromptAssistantButtonProps = {
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
className?: string;
asBadge?: boolean;
};
function useAssistantPalette() {
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return {
isDark,
textFrom: isDark ? "hsl(210, 55%, 88%)" : "hsl(210, 100%, 28%)",
textTo: isDark ? "hsl(210, 70%, 72%)" : "hsl(210, 75%, 40%)",
};
}
const shellClass = (opts: { asBadge?: boolean; disabled?: boolean; interactive?: boolean }) =>
cn(
"inline-flex items-center gap-2.5 rounded-full border transition-all duration-300",
"border-primary/20 bg-gradient-to-br from-primary/[0.07] via-card to-accent/30",
"shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_4px_16px_hsl(var(--primary)/0.06)]",
opts.asBadge ? "px-3.5 py-1.5" : "min-h-10 px-4 py-2",
opts.interactive &&
!opts.disabled &&
"hover:border-primary/35 hover:from-primary/[0.1] hover:shadow-[0_2px_8px_hsl(var(--foreground)/0.05),0_8px_24px_hsl(var(--primary)/0.12)] hover:-translate-y-px active:translate-y-0",
opts.disabled && "opacity-60"
);
export function PromptAssistantButton({
onClick,
disabled,
loading,
className,
asBadge = false,
}: PromptAssistantButtonProps) {
const palette = useAssistantPalette();
const label = loading ? (
<span className="text-sm font-medium text-muted-foreground">Gerando em segundo plano...</span>
) : (
<AnimatedGradientText
speed={0.75}
colorFrom={palette.textFrom}
colorTo={palette.textTo}
className="text-sm font-medium tracking-tight"
>
Assistente de criação de prompt
</AnimatedGradientText>
);
const content = (
<>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary",
asBadge ? "h-6 w-6" : "h-7 w-7"
)}
aria-hidden
>
<Sparkles className={asBadge ? "h-3 w-3" : "h-3.5 w-3.5"} />
</span>
{label}
</>
);
if (asBadge) {
return <span className={cn(shellClass({ asBadge: true }), className)}>{content}</span>;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
className={cn(
"rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none",
className
)}
>
<span className={shellClass({ disabled: disabled || loading, interactive: true })}>{content}</span>
</button>
);
}
+11 -41
View File
@@ -8,11 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ShimmerButton } from "@/components/ui/shimmer-button";
import { SparklesText } from "@/components/ui/sparkles-text";
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button"; import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { BorderBeam } from "@/components/ui/border-beam"; import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
import { Confetti, type ConfettiRef } from "@/components/ui/confetti"; import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePrompts } from "@/contexts/PromptsContext"; import { usePrompts } from "@/contexts/PromptsContext";
@@ -85,7 +82,7 @@ export function PromptsFormView() {
useEffect(() => { useEffect(() => {
if (id && !prompt && prompts.length > 0) { if (id && !prompt && prompts.length > 0) {
navigate("/codex/prompts", { replace: true }); navigate("/commander/prompts", { replace: true });
} }
}, [id, prompt, prompts.length, navigate]); }, [id, prompt, prompts.length, navigate]);
@@ -143,7 +140,7 @@ export function PromptsFormView() {
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]); setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
toast.success("Prompt criado."); toast.success("Prompt criado.");
} }
navigate("/codex/prompts"); navigate("/commander/prompts");
} catch (e: unknown) { } catch (e: unknown) {
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar."; const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
toast.error(message); toast.error(message);
@@ -224,7 +221,7 @@ export function PromptsFormView() {
/> />
<div className="border-b border-border p-3 md:p-6 flex-shrink-0"> <div className="border-b border-border p-3 md:p-6 flex-shrink-0">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}> <Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Voltar Voltar
</Button> </Button>
@@ -277,27 +274,11 @@ export function PromptsFormView() {
title="Gerando em andamento" title="Gerando em andamento"
/> />
)} )}
<ShimmerButton <PromptAssistantButton
type="button"
onClick={() => setIsAssistantOpen(true)} onClick={() => setIsAssistantOpen(true)}
disabled={isAssistantWorkingInBackground} disabled={isAssistantWorkingInBackground}
background="hsl(var(--card))" loading={isAssistantWorkingInBackground}
shimmerColor="hsl(var(--foreground) / 0.14)" />
className={cn(
"gap-2 px-5 py-2.5 text-sm font-medium text-card-foreground border-border shadow-md hover:shadow-lg transition-shadow",
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
)}
>
{isAssistantWorkingInBackground ? (
<span className="text-sm font-medium text-card-foreground">
Gerando em segundo plano...
</span>
) : (
<SparklesText sparklesCount={5} className="text-sm font-medium text-card-foreground">
Assistente de Criação de Prompt
</SparklesText>
)}
</ShimmerButton>
</div> </div>
</div> </div>
<Textarea <Textarea
@@ -327,21 +308,10 @@ export function PromptsFormView() {
)} )}
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}> <Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-8 gap-6 overflow-hidden rounded-xl border-border/80 bg-gradient-to-b from-background to-muted/20"> <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col gap-6 overflow-hidden rounded-xl border border-border/80 bg-card p-8 shadow-modal">
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none"> <DialogHeader className="space-y-4">
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
</div>
<DialogHeader className="space-y-4 relative z-10">
<div className="flex justify-center"> <div className="flex justify-center">
<div <PromptAssistantButton asBadge />
className={cn(
"inline-flex w-fit rounded-full border border-black/5 bg-neutral-100 text-base transition-all ease-in hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800"
)}
>
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-2 transition ease-out hover:text-neutral-600 hover:duration-300 hover:dark:text-neutral-400">
<span> Assistente de Criação de Prompt</span>
</AnimatedShinyText>
</div>
</div> </div>
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle> <DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
<DialogDescription className="text-base text-muted-foreground"> <DialogDescription className="text-base text-muted-foreground">
@@ -400,7 +370,7 @@ export function PromptsFormView() {
</div> </div>
<DialogFooter className="gap-3 pt-4 relative z-10"> <DialogFooter className="gap-3 pt-4 relative z-10">
<Button variant="outline" className="hover:bg-slate-100 hover:text-slate-800 dark:hover:bg-slate-800" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}> <Button variant="outline" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
Fechar Fechar
</Button> </Button>
<Button <Button
+4 -4
View File
@@ -127,7 +127,7 @@ export const PromptsView = () => {
const openEditFromDetails = () => { const openEditFromDetails = () => {
if (!selectedPrompt) return; if (!selectedPrompt) return;
setIsDetailsDialogOpen(false); setIsDetailsDialogOpen(false);
navigate(`/codex/prompts/${selectedPrompt.id}`); navigate(`/commander/prompts/${selectedPrompt.id}`);
}; };
const openDeleteDialog = (prompt: Prompt) => { const openDeleteDialog = (prompt: Prompt) => {
@@ -168,7 +168,7 @@ export const PromptsView = () => {
Prompts Prompts
</h1> </h1>
<div className="flex gap-2 w-full md:w-auto"> <div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => navigate("/codex/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm"> <Button onClick={() => navigate("/commander/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" /> <Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo prompt</span> <span className="hidden sm:inline">Novo prompt</span>
<span className="sm:hidden">Novo</span> <span className="sm:hidden">Novo</span>
@@ -231,7 +231,7 @@ export const PromptsView = () => {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center"> <div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<Button onClick={() => navigate("/codex/prompts/novo")}> <Button onClick={() => navigate("/commander/prompts/novo")}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Novo prompt Novo prompt
</Button> </Button>
@@ -287,7 +287,7 @@ export const PromptsView = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate(`/codex/prompts/${prompt.id}`)} onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
title="Editar" title="Editar"
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground" className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
> >
+13 -12
View File
@@ -1,8 +1,8 @@
import { type ComponentPropsWithoutRef } from "react" import { type ComponentPropsWithoutRef, type CSSProperties } from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> { export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
speed?: number speed?: number
colorFrom?: string colorFrom?: string
colorTo?: string colorTo?: string
@@ -12,23 +12,24 @@ export function AnimatedGradientText({
children, children,
className, className,
speed = 1, speed = 1,
colorFrom = "#ffaa40", colorFrom = "#0055a4",
colorTo = "#9c40ff", colorTo = "#0d8ecf",
style,
...props ...props
}: AnimatedGradientTextProps) { }: AnimatedGradientTextProps) {
const bgSize = `${speed * 300}%`
return ( return (
<span <span
className={cn("animated-gradient-text inline-block font-semibold", className)}
style={ style={
{ {
"--bg-size": `${speed * 300}%`, backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
"--color-from": colorFrom, backgroundSize: `${bgSize} 100%`,
"--color-to": colorTo, animation: "gradient-text-shift 12s ease-in-out infinite",
} as React.CSSProperties ...style,
} as CSSProperties
} }
className={cn(
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
className
)}
{...props} {...props}
> >
{children} {children}
+43 -68
View File
@@ -1,54 +1,26 @@
import { motion, MotionStyle, Transition } from "motion/react" import type { CSSProperties } from "react";
import { motion, type MotionStyle, type Transition } from "motion/react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
interface BorderBeamProps { interface BorderBeamProps {
/** size?: number;
* The size of the border beam. duration?: number;
*/ delay?: number;
size?: number colorFrom?: string;
/** colorTo?: string;
* The duration of the border beam. transition?: Transition;
*/ className?: string;
duration?: number style?: CSSProperties;
/** reverse?: boolean;
* The delay of the border beam. initialOffset?: number;
*/ borderWidth?: number;
delay?: number
/**
* The color of the border beam from.
*/
colorFrom?: string
/**
* The color of the border beam to.
*/
colorTo?: string
/**
* The motion transition of the border beam.
*/
transition?: Transition
/**
* The class name of the border beam.
*/
className?: string
/**
* The style of the border beam.
*/
style?: React.CSSProperties
/**
* Whether to reverse the animation direction.
*/
reverse?: boolean
/**
* The initial offset position (0-100).
*/
initialOffset?: number
/**
* The border width of the beam.
*/
borderWidth?: number
} }
/**
* Border Beam (Magic UI) — mesma lógica do registry oficial; estilos de máscara/borda
* em inline CSS para compatibilidade com Tailwind 3 (o doc do Magic UI usa utilitários v4).
*/
export const BorderBeam = ({ export const BorderBeam = ({
className, className,
size = 50, size = 50,
@@ -62,30 +34,33 @@ export const BorderBeam = ({
initialOffset = 0, initialOffset = 0,
borderWidth = 1, borderWidth = 1,
}: BorderBeamProps) => { }: BorderBeamProps) => {
const maskWrapperStyle: CSSProperties = {
borderWidth: `${borderWidth}px`,
borderStyle: "solid",
borderColor: "transparent",
maskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
maskClip: "padding-box, border-box",
maskComposite: "intersect",
maskRepeat: "no-repeat",
WebkitMaskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
WebkitMaskClip: "padding-box, border-box",
WebkitMaskComposite: "source-in",
};
const beamGradient: CSSProperties = {
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
backgroundImage: `linear-gradient(to left, ${colorFrom}, ${colorTo}, transparent)`,
};
return ( return (
<div <div
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent mask-[linear-gradient(transparent,transparent),linear-gradient(#000,#000)] mask-intersect [mask-clip:padding-box,border-box]" className="pointer-events-none absolute inset-0 rounded-[inherit]"
style={ style={maskWrapperStyle}
{
"--border-beam-width": `${borderWidth}px`,
} as React.CSSProperties
}
> >
<motion.div <motion.div
className={cn( className={cn("absolute aspect-square", className)}
"absolute aspect-square", style={{ ...beamGradient, ...style } as MotionStyle}
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
className
)}
style={
{
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
"--color-from": colorFrom,
"--color-to": colorTo,
...style,
} as MotionStyle
}
initial={{ offsetDistance: `${initialOffset}%` }} initial={{ offsetDistance: `${initialOffset}%` }}
animate={{ animate={{
offsetDistance: reverse offsetDistance: reverse
@@ -101,5 +76,5 @@ export const BorderBeam = ({
}} }}
/> />
</div> </div>
) );
} };
+1 -1
View File
@@ -39,7 +39,7 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />; return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
}, },
); );
Button.displayName = "Button"; Button.displayName = "Button";
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useRef, useState } from "react"
import {
animate,
motion,
useInView,
useMotionValue,
useReducedMotion,
useTransform,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
const BAND_HALF = 17
const SWEEP_START = -BAND_HALF
const SWEEP_END = 100 + BAND_HALF
const sweepEase = (t: number) =>
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF
const bandEnd = pos + BAND_HALF
if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})`
}
const n = colors.length
const parts: string[] = []
if (bandStart > 0)
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
parts.push(`${c} ${pct.toFixed(2)}%`)
})
if (bandEnd < 100)
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
return `linear-gradient(90deg, ${parts.join(", ")})`
}
function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement
Object.assign(ghost.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
width: "auto",
whiteSpace: "nowrap",
})
el.parentElement!.appendChild(ghost)
const widths = texts.map((t) => {
ghost.textContent = t
return ghost.getBoundingClientRect().width
})
ghost.remove()
return widths
}
/**
* Props for {@link DiaTextReveal}.
*/
export interface DiaTextRevealProps extends Omit<
HTMLMotionProps<"span">,
"ref" | "children" | "style" | "animate" | "transition" | "color"
> {
/**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/
text: string | string[]
/**
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/
colors?: string[]
/**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"`
*/
textColor?: string
/**
* Duration of one sweep pass, in seconds.
* @defaultValue `1.5`
*/
duration?: number
/**
* Delay before the sweep starts, in seconds.
* @defaultValue `0`
*/
delay?: number
/**
* When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false`
*/
repeat?: boolean
/**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5`
*/
repeatDelay?: number
/**
* If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true`
*/
startOnView?: boolean
/**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true`
*/
once?: boolean
/**
* Additional class names for the animated `span` (e.g. typography utilities).
*/
className?: string
/**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false`
*/
fixedWidth?: boolean
}
export function DiaTextReveal({
text,
colors = DEFAULT_COLORS,
textColor = "var(--foreground)",
duration = 1.5,
delay = 0,
repeat = false,
repeatDelay = 0.5,
startOnView = true,
once = true,
className,
fixedWidth = false,
...props
}: DiaTextRevealProps) {
const texts = Array.isArray(text) ? text : [text]
const isMulti = texts.length > 1
const prefersReducedMotion = useReducedMotion()
const spanRef = useRef<HTMLSpanElement>(null)
const optsRef = useRef({
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
})
optsRef.current = {
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
}
const indexRef = useRef(0)
const hasPlayedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const playRef = useRef<() => void>(null!)
const stopRef = useRef<(() => void) | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
const sweepPos = useMotionValue(SWEEP_START)
const backgroundImage = useTransform(sweepPos, (pos) =>
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
)
const isInView = useInView(spanRef, { once, amount: 0.1 })
useEffect(() => {
const el = spanRef.current
if (!el || !isMulti) return
setMeasuredWidths(measureWidths(el, texts))
}, [Array.isArray(text) ? text.join("\0") : text])
playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
sweepPos.set(SWEEP_START)
const controls = animate(sweepPos, SWEEP_END, {
duration,
delay,
ease: sweepEase,
onComplete() {
if (!repeat) return
timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length
indexRef.current = next
setActiveIndex(next)
playRef.current()
}, repeatDelay * 1000)
},
})
stopRef.current = () => controls.stop()
}
useEffect(() => {
if (prefersReducedMotion) {
sweepPos.set(SWEEP_END)
return
}
if (startOnView && !isInView) return
if (once && hasPlayedRef.current) return
hasPlayedRef.current = true
playRef.current()
return () => {
stopRef.current?.()
clearTimeout(timerRef.current)
}
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
const fixedW =
isMulti && fixedWidth && measuredWidths.length > 0
? Math.max(...measuredWidths)
: undefined
const animatedW =
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
? measuredWidths[activeIndex]
: undefined
return (
<motion.span
ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{
transform: "translateY(-2px)",
color: "transparent",
backgroundClip: "text",
WebkitBackgroundClip: "text",
backgroundSize: "100% 100%",
backgroundImage,
...(isMulti && {
display: "inline-block",
overflow: "hidden",
whiteSpace: "nowrap",
verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }),
}),
}}
animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props}
>
{texts[activeIndex]}
</motion.span>
)
}
+193
View File
@@ -0,0 +1,193 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent,
type ReactNode,
} from "react";
import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface MagicCardBaseProps {
children?: ReactNode;
className?: string;
gradientSize?: number;
gradientFrom?: string;
gradientTo?: string;
}
interface MagicCardGradientProps extends MagicCardBaseProps {
mode?: "gradient";
glowFrom?: never;
glowTo?: never;
glowAngle?: never;
glowSize?: never;
glowBlur?: never;
glowOpacity?: never;
}
interface MagicCardOrbProps extends MagicCardBaseProps {
mode: "orb";
glowFrom?: string;
glowTo?: string;
glowAngle?: number;
glowSize?: number;
glowBlur?: number;
glowOpacity?: number;
}
export type MagicCardProps = MagicCardGradientProps | MagicCardOrbProps;
type ResetReason = "enter" | "leave" | "global" | "init";
function isOrbMode(props: MagicCardProps): props is MagicCardOrbProps {
return props.mode === "orb";
}
export function MagicCard(props: MagicCardProps) {
const {
children,
className,
gradientSize = 200,
gradientFrom = "#9E7AFF",
gradientTo = "#FE8BBB",
mode = "gradient",
} = props;
const glowFrom = isOrbMode(props) ? (props.glowFrom ?? "#ee4f27") : "#ee4f27";
const glowTo = isOrbMode(props) ? (props.glowTo ?? "#6b21ef") : "#6b21ef";
const glowAngle = isOrbMode(props) ? (props.glowAngle ?? 90) : 90;
const glowSize = isOrbMode(props) ? (props.glowSize ?? 420) : 420;
const glowBlur = isOrbMode(props) ? (props.glowBlur ?? 60) : 60;
const glowOpacity = isOrbMode(props) ? (props.glowOpacity ?? 0.9) : 0.9;
const { theme, systemTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDarkTheme = useMemo(() => {
if (!mounted) return true;
const currentTheme = theme === "system" ? systemTheme : theme;
return currentTheme === "dark";
}, [theme, systemTheme, mounted]);
const mouseX = useMotionValue(-gradientSize);
const mouseY = useMotionValue(-gradientSize);
const orbX = useSpring(mouseX, { stiffness: 250, damping: 30, mass: 0.6 });
const orbY = useSpring(mouseY, { stiffness: 250, damping: 30, mass: 0.6 });
const orbVisible = useSpring(0, { stiffness: 300, damping: 35 });
const modeRef = useRef(mode);
const glowOpacityRef = useRef(glowOpacity);
const gradientSizeRef = useRef(gradientSize);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
glowOpacityRef.current = glowOpacity;
}, [glowOpacity]);
useEffect(() => {
gradientSizeRef.current = gradientSize;
}, [gradientSize]);
const reset = useCallback(
(reason: ResetReason = "leave") => {
const currentMode = modeRef.current;
if (currentMode === "orb") {
if (reason === "enter") orbVisible.set(glowOpacityRef.current);
else orbVisible.set(0);
return;
}
const off = -gradientSizeRef.current;
mouseX.set(off);
mouseY.set(off);
},
[mouseX, mouseY, orbVisible],
);
const handlePointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
mouseX.set(e.clientX - rect.left);
mouseY.set(e.clientY - rect.top);
},
[mouseX, mouseY],
);
useEffect(() => {
reset("init");
}, [reset]);
useEffect(() => {
const handleGlobalPointerOut = (e: PointerEvent) => {
if (!e.relatedTarget) reset("global");
};
const handleBlur = () => reset("global");
const handleVisibility = () => {
if (document.visibilityState !== "visible") reset("global");
};
window.addEventListener("pointerout", handleGlobalPointerOut);
window.addEventListener("blur", handleBlur);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
window.removeEventListener("pointerout", handleGlobalPointerOut);
window.removeEventListener("blur", handleBlur);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [reset]);
const bgMotion = useMotionTemplate`
linear-gradient(hsl(var(--background)) 0 0) padding-box,
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
hsl(var(--border)) 100%
) border-box
`;
return (
<motion.div
className={cn("relative isolate overflow-hidden rounded-[inherit] border border-transparent", className)}
onPointerMove={handlePointerMove}
onPointerLeave={() => reset("leave")}
onPointerEnter={() => reset("enter")}
style={{ background: bgMotion }}
>
<div className="absolute inset-px z-20 rounded-[inherit] bg-background" />
{mode === "orb" && (
<motion.div
suppressHydrationWarning
aria-hidden
className="pointer-events-none absolute z-30"
style={{
width: glowSize,
height: glowSize,
x: orbX,
y: orbY,
translateX: "-50%",
translateY: "-50%",
borderRadius: 9999,
filter: `blur(${glowBlur}px)`,
opacity: orbVisible,
background: `linear-gradient(${glowAngle}deg, ${glowFrom}, ${glowTo})`,
mixBlendMode: isDarkTheme ? "screen" : "multiply",
willChange: "transform, opacity",
}}
/>
)}
<div className="relative z-40">{children}</div>
</motion.div>
);
}
+4 -1
View File
@@ -8,7 +8,9 @@ const ScrollArea = React.forwardRef<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}> <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport> <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block [&>div]:min-w-0 [&>div]:w-full [&>div]:max-w-full">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
@@ -36,3 +38,4 @@ const ScrollBar = React.forwardRef<
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar }; export { ScrollArea, ScrollBar };
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import type { MeData, PapelUsuario } from "@/services/fechamento/authMe";
export type AccessBlockReason = "nao_cadastrado" | "inativo" | "erro" | "bootstrap";
type AuthAccessState = {
loading: boolean;
isAuthenticated: boolean;
hasAccess: boolean;
papel: PapelUsuario | null;
me: MeData | null;
reason: AccessBlockReason | null;
errorMessage: string | null;
};
type AuthAccessContextValue = AuthAccessState & {
setAuthState: (state: Partial<AuthAccessState>) => void;
resetAuthState: () => void;
};
const initialState: AuthAccessState = {
loading: true,
isAuthenticated: false,
hasAccess: false,
papel: null,
me: null,
reason: null,
errorMessage: null,
};
const AuthAccessContext = createContext<AuthAccessContextValue | undefined>(undefined);
export function AuthAccessProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthAccessState>(initialState);
const setAuthState = useCallback((partial: Partial<AuthAccessState>) => {
setState((prev) => ({ ...prev, ...partial }));
}, []);
const resetAuthState = useCallback(() => {
setState(initialState);
}, []);
const value = useMemo<AuthAccessContextValue>(
() => ({
...state,
setAuthState,
resetAuthState,
}),
[state, setAuthState, resetAuthState],
);
return <AuthAccessContext.Provider value={value}>{children}</AuthAccessContext.Provider>;
}
export function useAuthAccess() {
const ctx = useContext(AuthAccessContext);
if (!ctx) {
throw new Error("useAuthAccess deve ser usado dentro de AuthAccessProvider.");
}
return ctx;
}
+181 -71
View File
@@ -6,62 +6,123 @@
@layer base { @layer base {
:root { :root {
/* HGTX Codex - Light Theme */ /* ─── HGTX Commander — Design System Tokens (compartilhados) ─── */
--background: 210 40% 98%;
--foreground: 222 47% 11%; /* Tipografia */
--fs-display: 32px;
--fs-h1: 24px;
--fs-h2: 18px;
--fs-h3: 15px;
--fs-body: 14px;
--fs-small: 12px;
--fs-caption: 11px;
--lh-tight: 1.25;
--lh-body: 1.5;
--lh-relaxed: 1.65;
--fw-regular: 400;
--fw-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
/* Espaçamento */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 32px;
--space-8: 48px;
--space-9: 64px;
--section-y: 64px;
--section-x: 40px;
--nav-h: 56px;
--container: 1200px;
/* Border radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 999px;
/* ─── Light Theme ─── */
--background: 0 0% 100%;
--foreground: 209 11% 15%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222 47% 11%; --card-foreground: 209 11% 15%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 47% 11%; --popover-foreground: 209 11% 15%;
--primary: 190 100% 45%; /* Azul primário vivo (referência ~#004080 / #0055a4) */
--primary: 210 100% 33%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-hover: 210 100% 28%;
--primary-pressed: 210 100% 24%;
--primary-light: 210 55% 96%;
--secondary: 210 100% 45%; /* Secondary neutro para botões */
--secondary-foreground: 0 0% 100%; --secondary: 210 6% 60%;
--secondary-foreground: 209 11% 15%;
--muted: 210 40% 96%; /* Muted — bg suave */
--muted-foreground: 215 16% 47%; --muted: 210 17% 96%;
--muted-foreground: 209 7% 46%;
--accent: 190 100% 50%; /* Accent = fundo nav ativo */
--accent-foreground: 0 0% 100%; --accent: 210 70% 94%;
--accent-foreground: 210 100% 33%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--success: 142 71% 45%; /* Sucesso: #28a745 */
--success: 134 61% 41%;
--success-foreground: 0 0% 100%; --success-foreground: 0 0% 100%;
--warning: 38 92% 50%; --warning: 38 92% 50%;
--warning-foreground: 222 47% 11%; --warning-foreground: 209 11% 15%;
--border: 214 32% 91%; /* Borda: #e5e7eb */
--input: 214 32% 91%; --border: 220 13% 91%;
--ring: 190 100% 45%; /* --input no shadcn = cor da BORDA de Input/Select/Textarea */
--input: 220 9% 83%;
--ring: 210 100% 33%;
--radius: 0.75rem; --radius: 0.5rem;
/* Sidebar specific - Light */ /* Sidebar Light */
--sidebar-background: 210 40% 99%; --sidebar-background: 210 17% 98%;
--sidebar-foreground: 222 47% 11%; --sidebar-foreground: 209 11% 15%;
--sidebar-primary: 190 100% 45%; --sidebar-primary: 210 100% 33%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 95%; --sidebar-accent: 210 70% 94%;
--sidebar-accent-foreground: 222 47% 11%; --sidebar-accent-foreground: 210 100% 33%;
--sidebar-border: 214 32% 88%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 190 100% 45%; --sidebar-ring: 210 100% 33%;
/* Custom gradients - Light */ /* Tokens extras Light */
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 45%) 0%, hsl(210 100% 45%) 100%); --bubble-bot: 209 36% 96%;
--gradient-subtle: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(210 40% 95%) 100%); --nav-active: 210 70% 94%;
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.15); --input-bg: 0 0% 100%;
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.12); --placeholder: 220 9% 66%;
--btn-secondary-border: 220 9% 83%;
--border-subtle: 220 13% 95%;
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-modal: 0 16px 40px rgba(0, 0, 0, 0.12);
--focus-ring: rgba(0, 85, 164, 0.28);
/* Gradientes — Light */
--gradient-cyber: linear-gradient(135deg, hsl(210 100% 33%) 0%, hsl(210 90% 42%) 100%);
--gradient-subtle: linear-gradient(180deg, hsl(0 0% 100%) 0%, hsl(210 17% 98%) 100%);
--glow-cyan: 0 0 40px hsl(210 100% 33% / 0.15);
--glow-blue: 0 0 30px hsl(210 90% 42% / 0.12);
/* Animation speeds */
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); --transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
--color-1: oklch(66.2% 0.225 25.9); --color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302); --color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251); --color-3: oklch(69.6% 0.165 251);
@@ -70,62 +131,85 @@
} }
.dark { .dark {
/* HGTX Codex - Dark Theme */ /* ─── Dark Theme ─── */
--background: 222 47% 5%; /* Fundo principal = mesma família da sidebar (216 50% 9%), levemente mais profundo */
--foreground: 200 100% 95%; --background: 216 48% 8%;
--foreground: 0 0% 100%;
--card: 222 40% 8%; /* Cards/painéis um tom acima do fundo para hierarquia sutil */
--card-foreground: 200 100% 95%; --card: 216 42% 11%;
--card-foreground: 0 0% 100%;
--popover: 222 45% 10%; --popover: 216 42% 11%;
--popover-foreground: 200 100% 95%; --popover-foreground: 0 0% 100%;
--primary: 190 100% 50%; /* Azul primário dark — vibrante em fundo escuro */
--primary-foreground: 222 47% 5%; --primary: 210 90% 52%;
--primary-foreground: 0 0% 100%;
--primary-hover: 210 95% 46%;
--primary-pressed: 210 100% 40%;
--primary-light: 210 50% 14%;
--secondary: 210 100% 50%; --secondary: 216 38% 13%;
--secondary-foreground: 222 47% 5%; --secondary-foreground: 0 0% 100%;
--muted: 222 30% 15%; --muted: 216 38% 13%;
--muted-foreground: 200 20% 65%; --muted-foreground: 212 9% 58%;
--accent: 190 100% 60%; /* Accent dark */
--accent-foreground: 222 47% 5%; --accent: 210 45% 16%;
--accent-foreground: 210 85% 62%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 200 100% 95%; --destructive-foreground: 0 0% 100%;
--success: 142 71% 45%; /* Sucesso dark: #23d160 */
--success-foreground: 222 47% 5%; --success: 141 71% 48%;
--success-foreground: 216 48% 8%;
--warning: 38 92% 50%; --warning: 38 92% 50%;
--warning-foreground: 222 47% 5%; --warning-foreground: 216 48% 8%;
--border: 222 30% 18%; /* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
--input: 222 30% 18%; --border: 216 18% 26%;
--ring: 190 100% 50%; /* --input no shadcn = cor da BORDA de Input/Select/Textarea (não o fundo) */
--input: 216 16% 30%;
--ring: 210 90% 52%;
/* Sidebar specific - Dark */ /* Sidebar Dark */
--sidebar-background: 222 45% 7%; --sidebar-background: 216 50% 9%;
--sidebar-foreground: 200 100% 90%; --sidebar-foreground: 0 0% 100%;
--sidebar-primary: 190 100% 50%; --sidebar-primary: 210 90% 52%;
--sidebar-primary-foreground: 222 47% 5%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 222 35% 12%; --sidebar-accent: 210 45% 16%;
--sidebar-accent-foreground: 190 100% 60%; --sidebar-accent-foreground: 210 85% 62%;
--sidebar-border: 222 30% 15%; --sidebar-border: 212 12% 21%;
--sidebar-ring: 190 100% 50%; --sidebar-ring: 210 90% 52%;
/* Tokens extras — Dark */
--bubble-bot: 216 36% 14%;
--nav-active: 210 45% 16%;
--input-bg: 216 40% 10%;
--placeholder: 212 10% 31%;
--btn-secondary-border: 216 16% 30%;
--border-subtle: 216 22% 20%;
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.35);
--shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.55);
--focus-ring: rgba(51, 136, 255, 0.35);
/* Gradientes — Dark */
--gradient-cyber: linear-gradient(135deg, hsl(210 90% 52%) 0%, hsl(210 100% 40%) 100%);
--gradient-subtle: linear-gradient(180deg, hsl(216 42% 11%) 0%, hsl(216 48% 8%) 100%);
--glow-cyan: 0 0 40px hsl(210 90% 52% / 0.35);
--glow-blue: 0 0 30px hsl(210 100% 40% / 0.28);
/* Custom gradients - Dark */
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 50%) 0%, hsl(210 100% 50%) 100%);
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%);
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3);
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25);
--color-1: oklch(66.2% 0.225 25.9); --color-1: oklch(66.2% 0.225 25.9);
--color-2: oklch(60.4% 0.26 302); --color-2: oklch(60.4% 0.26 302);
--color-3: oklch(69.6% 0.165 251); --color-3: oklch(69.6% 0.165 251);
--color-4: oklch(80.2% 0.134 225); --color-4: oklch(80.2% 0.134 225);
--color-5: oklch(90.7% 0.231 133); --color-5: oklch(90.7% 0.231 133);
} }
.theme { .theme {
--animate-shiny-text: shiny-text 8s infinite; --animate-shiny-text: shiny-text 8s infinite;
--animate-gradient: gradient 8s linear infinite; --animate-gradient: gradient 8s linear infinite;
@@ -144,12 +228,21 @@
@apply border-border transition-colors; @apply border-border transition-colors;
} }
html,
body,
#root {
height: 100%;
}
body { body {
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
} }
/* Scrollbar styling */ #root {
@apply min-h-0;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -179,15 +272,22 @@
.cyber-border { .cyber-border {
@apply border border-primary/30 rounded-lg; @apply border border-primary/30 rounded-lg;
box-shadow: 0 0 20px hsl(190 100% 50% / 0.1); box-shadow: 0 0 20px hsl(210 100% 33% / 0.1);
} }
.gradient-text { .gradient-text {
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent; @apply bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent;
}
.animated-gradient-text {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
} }
.metric-card { .metric-card {
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30; @apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
box-shadow: var(--shadow-card);
} }
.nav-item { .nav-item {
@@ -246,6 +346,16 @@
} }
} }
@keyframes gradient-text-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@theme inline { @theme inline {
@keyframes shiny-text { @keyframes shiny-text {
0%, 90%, 100% { 0%, 90%, 100% {
+49
View File
@@ -0,0 +1,49 @@
import { Navigate, Route, Routes } from "react-router-dom";
import React from "react";
import { GlobalFunctions } from "@/GlobalFunctions";
import { PromptsProvider } from "@/contexts/PromptsContext";
import { MainLayout } from "./components/layout/MainLayout";
import { ChatView } from "@/components/chat/ChatView";
import { ImageView } from "@/components/images/ImageView";
import { TranscriptionView } from "@/components/audio/TranscriptionView";
import { GenerationView } from "@/components/audio/GenerationView";
import { PromptsView } from "@/components/prompts/PromptsView";
import { PromptsFormView } from "@/components/prompts/PromptsFormView";
import { AreasView } from "@/components/areas/AreasView";
import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
import { CommanderCsvView } from "@/components/commander-csv/CommanderCsvView";
const CommanderApp = () => {
React.useEffect(() => {
if (!GlobalFunctions.isUsuarioLogado()) {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
}
}, []);
return (
<PromptsProvider>
<MainLayout>
<Routes>
<Route index element={<Navigate to="bate-papo" replace />} />
<Route path="bate-papo" element={<ChatView />} />
<Route path="imagens" element={<ImageView />} />
<Route path="transcricao-audio" element={<TranscriptionView />} />
<Route path="geracao-audio" element={<GenerationView />} />
<Route path="prompts" element={<PromptsView />} />
<Route path="prompts/novo" element={<PromptsFormView />} />
<Route path="prompts/:id" element={<PromptsFormView />} />
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
<Route path="areas" element={<AreasView />} />
<Route path="commander-csv" element={<CommanderCsvView />} />
<Route path="*" element={<Navigate to="bate-papo" replace />} />
</Routes>
</MainLayout>
</PromptsProvider>
);
};
export default CommanderApp;
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
interface CommanderLogoProps {
className?: string;
/** Classes do container (expandido: proporção 3:2 da arte; recolhido: quadrado) */
heightClass?: string;
}
export const CommanderLogo = ({
className,
heightClass = "h-36 w-full",
}: CommanderLogoProps) => {
const imgClass = cn(
"absolute inset-0 h-full w-full object-contain object-left",
className
);
return (
<div className={cn("relative w-full shrink-0", heightClass)}>
<img
src="/hgtx-commander-light.png"
alt="HGTX Commander"
className={cn(imgClass, "dark:hidden")}
/>
<img
src="/hgtx-commander-dark.png"
alt="HGTX Commander"
className={cn(imgClass, "hidden dark:block")}
/>
</div>
);
};
@@ -0,0 +1,217 @@
import { useState, type ComponentType } from "react";
import { NavLink } from "react-router-dom";
import {
MessageSquare,
Image,
AudioLines,
Mic,
ArrowLeft,
ChevronLeft,
ChevronRight,
Bot,
FileText,
Layers,
FileSpreadsheet,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalFunctions } from "@/GlobalFunctions";
import { CommanderLogo } from "./CommanderLogo";
import { cn } from "@/lib/utils";
type NavItem = {
path: string;
label: string;
icon: ComponentType<{ className?: string }>;
};
const navSections: { title: string; items: NavItem[] }[] = [
{
title: "I.A",
items: [
{ path: "bate-papo", label: "Bate-papo", icon: MessageSquare },
{ path: "imagens", label: "Imagens", icon: Image },
{ path: "transcricao-audio", label: "Transcrição de Áudio", icon: AudioLines },
{ path: "geracao-audio", label: "Geração de Áudio", icon: Mic },
],
},
{
title: "Parecer Jurídico",
items: [
{ path: "areas", label: "Áreas", icon: Layers },
{ path: "parecer-juridico", label: "Parecer Jurídico", icon: Bot },
{ path: "prompts", label: "Prompts", icon: FileText },
],
},
{
title: "Personalizado",
items: [
{ path: "commander-csv", label: "Commander CSV", icon: FileSpreadsheet },
],
},
];
const navItems = navSections.flatMap((section) => section.items);
const navLinkClass = (isActive: boolean, collapsed?: boolean) =>
cn(
"flex items-center rounded-lg text-sm font-medium transition-all duration-200 w-full",
collapsed
? "justify-center p-3"
: "gap-3 px-3 py-2.5",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground"
);
interface MainLayoutProps {
children: React.ReactNode;
}
export const MainLayout = ({ children }: MainLayoutProps) => {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex h-screen w-full bg-background overflow-hidden">
{/* Sidebar desktop expandido */}
{!collapsed ? (
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
{/* Logo + recolher lado a lado */}
<div className="flex items-center gap-2 border-b border-sidebar-border px-4 py-3">
<div className="min-w-0 flex-1">
<CommanderLogo />
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(true)}
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
title="Recolher menu"
>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
{/* Navegação */}
<nav className="flex-1 space-y-3 overflow-y-auto px-3 py-4">
{navSections.map((section) => (
<div key={section.title} className="space-y-1">
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{section.title}
</p>
{section.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) => navLinkClass(isActive)}
>
<item.icon className="h-5 w-5 shrink-0" />
<span>{item.label}</span>
</NavLink>
))}
</div>
))}
</nav>
<Separator className="bg-sidebar-border" />
{/* Rodapé */}
<div className="p-4 border-t border-sidebar-border space-y-2">
<div className="flex items-center justify-between px-3">
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
<Button
onClick={GlobalFunctions.handleNavigateBack}
variant="ghost"
className="w-full justify-start gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60"
>
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</div>
</aside>
) : (
/* Sidebar desktop recolhido */
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
<div className="p-3 border-b border-sidebar-border space-y-2">
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(false)}
className="w-full text-muted-foreground hover:text-foreground"
title="Expandir"
>
<ChevronRight className="w-4 h-4" />
</Button>
<div className="flex justify-center">
<CommanderLogo heightClass="h-8 w-8" />
</div>
</div>
<nav className="flex-1 px-2 py-2 space-y-0.5">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) => navLinkClass(isActive, true)}
title={item.label}
>
<item.icon className="w-5 h-5 shrink-0" />
</NavLink>
))}
</nav>
<Separator className="bg-sidebar-border" />
<div className="p-3 border-t border-sidebar-border space-y-2">
<div className="flex justify-center">
<ThemeToggle />
</div>
<Button
variant="ghost"
size="icon"
className="w-full text-sidebar-foreground hover:bg-sidebar-accent/60"
title="Voltar"
onClick={GlobalFunctions.handleNavigateBack}
>
<ArrowLeft className="w-4 h-4" />
</Button>
</div>
</aside>
)}
{/* Conteúdo principal */}
<main className="flex flex-1 flex-col min-h-0 overflow-hidden w-full">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
</main>
{/* Navegação mobile (bottom bar) */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "bate-papo"}
className={({ isActive }) =>
cn(
"flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all",
isActive ? "text-primary" : "text-sidebar-foreground/70"
)
}
>
{({ isActive }) => (
<>
<item.icon className={cn("w-5 h-5", isActive && "text-primary")} />
<span className="text-xs font-medium">{item.label}</span>
</>
)}
</NavLink>
))}
</nav>
</div>
);
};
@@ -10,6 +10,7 @@ import {
DollarSign, DollarSign,
UserCircle, UserCircle,
} from "lucide-react"; } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
@@ -27,12 +28,12 @@ export function AppSidebar() {
<> <>
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border"> <div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center cyber-glow"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<Bot className="w-5 h-5 text-primary" /> <Bot className="w-5 h-5 text-primary" />
</div> </div>
{!collapsed && ( {!collapsed && (
<div className="animate-fade-in"> <div className="animate-fade-in">
<h1 className="text-sidebar-foreground font-semibold text-lg">AI Agent</h1> <h1 className="text-sidebar-foreground font-bold text-h2">AI Agent</h1>
<p className="text-muted-foreground text-xs">Painel Admin</p> <p className="text-muted-foreground text-xs">Painel Admin</p>
</div> </div>
)} )}
@@ -61,16 +62,36 @@ export function AppSidebar() {
))} ))}
</nav> </nav>
{/* Collapse button - desktop only */} <div
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border"> className={cn(
"mt-auto space-y-2 border-t border-sidebar-border",
collapsed ? "p-3" : "p-4",
)}
>
<div
className={cn(
"flex items-center justify-between px-3",
collapsed && "lg:hidden",
)}
>
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
{collapsed && (
<div className="hidden justify-center lg:flex">
<ThemeToggle />
</div>
)}
<button <button
type="button"
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className="nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full justify-center lg:justify-start text-muted-foreground hover:text-foreground hover:bg-sidebar-accent" className="nav-item hidden w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent/60 hover:text-foreground lg:flex lg:justify-start"
> >
<ChevronLeft <ChevronLeft
className={cn( className={cn(
"w-5 h-5 transition-transform duration-300 flex-shrink-0", "h-5 w-5 flex-shrink-0 transition-transform duration-300",
collapsed && "rotate-180" collapsed && "rotate-180",
)} )}
/> />
{!collapsed && <span>Recolher</span>} {!collapsed && <span>Recolher</span>}
@@ -674,7 +674,7 @@ export default function Financas() {
variant="outline" variant="outline"
className={ className={
expense.tipo === "corporativo" expense.tipo === "corporativo"
? "bg-secondary/10 text-secondary border-secondary/20" ? "bg-secondary/10 text-secondary border-secondary/20 dark:bg-white/10 dark:text-white dark:border-white/25"
: "bg-warning/10 text-warning border-warning/20" : "bg-warning/10 text-warning border-warning/20"
} }
> >
+139
View File
@@ -0,0 +1,139 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { MainLayout } from "@/modules/intelligence-score/components/layout/MainLayout";
import DashboardPontos from "@/modules/intelligence-score/pages/DashboardPontos";
import Fechamentos from "@/modules/intelligence-score/pages/Fechamentos";
import CompetenciaFechamentos from "@/modules/intelligence-score/pages/CompetenciaFechamentos";
import FechamentoDetalhes from "@/modules/intelligence-score/pages/FechamentoDetalhes";
import BancoPontos from "@/modules/intelligence-score/pages/BancoPontos";
import BancoPontosExtrato from "@/modules/intelligence-score/pages/BancoPontosExtrato";
import Parceiros from "@/modules/intelligence-score/pages/Parceiros";
import Usuarios from "@/modules/intelligence-score/pages/Usuarios";
import Configuracoes from "@/modules/intelligence-score/pages/Configuracoes";
import MeuPerfil from "@/modules/intelligence-score/pages/MeuPerfil";
import MeuFechamento from "@/modules/intelligence-score/pages/MeuFechamento";
import MeuFechamentoBancoPontos from "@/modules/intelligence-score/pages/MeuFechamentoBancoPontos";
import NotFound from "@/modules/intelligence-score/pages/NotFound";
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
import { AuthGate } from "@/components/auth/AuthGate";
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
function RequireStaffRoute({ children }: { children: JSX.Element }) {
const { papel } = useAuthAccess();
if (papel === "admin" || papel === "supervisor") {
return children;
}
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
}
function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element }) {
const { papel } = useAuthAccess();
const location = useLocation();
const readonlyView = Boolean((location.state as { readonlyView?: boolean } | null)?.readonlyView);
if (papel === "admin" || papel === "supervisor") {
return children;
}
if (papel === "parceiro" && readonlyView) {
return children;
}
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
}
const IntelligenceScoreApp = () => {
return (
<AuthAccessProvider>
<AuthGate>
<UnidadeGate>
<MainLayout>
<Routes>
<Route index element={<Navigate to="dashboard" replace />} />
<Route
path="dashboard"
element={
<RequireStaffRoute>
<DashboardPontos />
</RequireStaffRoute>
}
/>
<Route
path="gerenciar-fechamentos"
element={
<RequireStaffRoute>
<Fechamentos />
</RequireStaffRoute>
}
/>
<Route
path="competencias/:id"
element={
<RequireStaffRoute>
<CompetenciaFechamentos />
</RequireStaffRoute>
}
/>
<Route
path="fechamentos/:id"
element={
<RequireFechamentoDetalheRoute>
<FechamentoDetalhes />
</RequireFechamentoDetalheRoute>
}
/>
<Route
path="banco-pontos"
element={
<RequireStaffRoute>
<BancoPontos />
</RequireStaffRoute>
}
/>
<Route
path="banco-pontos/:parceiroId"
element={
<RequireStaffRoute>
<BancoPontosExtrato />
</RequireStaffRoute>
}
/>
<Route path="meu-perfil" element={<MeuPerfil />} />
<Route path="meu-fechamento" element={<MeuFechamento />} />
<Route path="meu-fechamento/banco-pontos" element={<MeuFechamentoBancoPontos />} />
<Route
path="parceiros"
element={
<RequireStaffRoute>
<Parceiros />
</RequireStaffRoute>
}
/>
<Route
path="usuarios"
element={
<RequireStaffRoute>
<Usuarios />
</RequireStaffRoute>
}
/>
<Route
path="configuracoes"
element={
<RequireStaffRoute>
<Configuracoes />
</RequireStaffRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
</UnidadeGate>
</AuthGate>
</AuthAccessProvider>
);
};
export default IntelligenceScoreApp;
@@ -0,0 +1,275 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ClipboardList,
Landmark,
LayoutDashboard,
Menu,
ReceiptText,
Settings,
UserCircle2,
Users,
Wallet,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
import { GlobalFunctions } from "@/GlobalFunctions";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import type { Capabilities, PapelUsuario } from "@/services/fechamento/authMe";
function canSeeNavItem(path: string, caps: Capabilities | undefined, papel: PapelUsuario | null): boolean {
if (!caps || !papel) return false;
if (path === "meu-fechamento" || path === "meu-perfil") {
return papel === "admin" || papel === "parceiro" || papel === "supervisor";
}
if (path === "configuracoes") {
return papel === "admin" || papel === "supervisor";
}
if (path === "dashboard") {
return (papel === "admin" || papel === "supervisor") && caps.competencias.listar;
}
if (path === "gerenciar-fechamentos") return caps.competencias.listar;
if (path === "banco-pontos") return caps.bancoPontos.acessoAdmin;
if (path === "parceiros") return caps.parceiros.listar;
if (path === "usuarios") return caps.usuarios.listar;
return false;
}
const navItems = [
{
section: "Fechamento" as const,
title: "Dashboard",
path: "dashboard",
icon: LayoutDashboard,
},
{
section: "Fechamento" as const,
title: "Gerenciar Fechamentos",
path: "gerenciar-fechamentos",
icon: ClipboardList,
},
{
section: "Fechamento" as const,
title: "Banco de Pontos",
path: "banco-pontos",
icon: Landmark,
},
{
section: "Fechamento" as const,
title: "Meu Fechamento",
path: "meu-fechamento",
icon: ReceiptText,
},
{
section: "Configurações" as const,
title: "Parceiros",
path: "parceiros",
icon: Wallet,
},
{
section: "Configurações" as const,
title: "Usuários",
path: "usuarios",
icon: Users,
},
{
section: "Configurações" as const,
title: "Configurações",
path: "configuracoes",
icon: Settings,
},
{
section: "Configurações" as const,
title: "Meu Perfil",
path: "meu-perfil",
icon: UserCircle2,
},
];
export function AppSidebar() {
const { papel, me } = useAuthAccess();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const caps = me?.capabilities;
const allowedNavItems = navItems.filter((item) => canSeeNavItem(item.path, caps, papel));
const sections = ["Fechamento", "Configurações"] as const;
const painelLabel =
papel === "supervisor" ? "Painel Supervisor" : papel === "admin" ? "Painel Admin" : "Painel Parceiro";
const SidebarContent = () => (
<>
<div
className={cn(
"border-b border-sidebar-border max-lg:pt-12",
collapsed
? "flex flex-col items-center gap-2 px-2 py-3"
: "flex items-center gap-2 px-3 py-4 lg:gap-3",
)}
>
{!collapsed ? (
<>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 animate-fade-in">
<h1 className="truncate text-h2 font-bold text-sidebar-foreground">HGTX Intelligence Score</h1>
<p className="text-xs text-muted-foreground">{painelLabel}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden h-8 w-8 flex-shrink-0 text-muted-foreground hover:text-foreground lg:inline-flex"
title="Recolher menu"
aria-label="Recolher menu"
onClick={() => setCollapsed(true)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
</>
) : (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden h-8 w-8 text-muted-foreground hover:text-foreground lg:inline-flex"
title="Expandir menu"
aria-label="Expandir menu"
onClick={() => setCollapsed(false)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
</>
)}
</div>
<nav className="flex-1 space-y-3 px-3 py-4">
{sections.map((section) => {
const sectionItems = allowedNavItems.filter((item) => item.section === section);
if (sectionItems.length === 0) {
return null;
}
return (
<div key={section} className="space-y-1">
{!collapsed ? (
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
{section}
</p>
) : null}
{sectionItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === "dashboard"}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
isActive
? "bg-sidebar-accent font-medium text-primary"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
)
}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</div>
);
})}
</nav>
<div
className={cn(
"mt-auto space-y-2 border-t border-sidebar-border",
collapsed ? "p-3" : "p-4",
)}
>
{collapsed ? (
<div className="flex justify-center">
<ThemeToggle />
</div>
) : (
<div className="flex items-center justify-between px-3">
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
<ThemeToggle />
</div>
)}
<Button
type="button"
variant="ghost"
onClick={() => {
GlobalFunctions.handleNavigateBack();
setMobileOpen(false);
}}
className={cn(
"w-full gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
collapsed ? "justify-center px-0" : "justify-start",
)}
title="Voltar ao CORE HGTX"
>
<ArrowLeft className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Voltar</span>}
</Button>
</div>
</>
);
return (
<>
<button
onClick={() => {
setCollapsed(false);
setMobileOpen(true);
}}
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-300 lg:hidden",
mobileOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
>
<X className="h-5 w-5" />
</button>
<SidebarContent />
</aside>
<aside
className={cn(
"sticky top-0 hidden h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-300 lg:flex",
collapsed ? "w-[72px]" : "w-64",
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -0,0 +1,18 @@
import { ReactNode } from "react";
import { AppSidebar } from "@/modules/intelligence-score/components/layout/AppSidebar";
type MainLayoutProps = {
children: ReactNode;
};
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 pt-16 lg:p-8 lg:pt-8">{children}</div>
</main>
</div>
);
}
@@ -0,0 +1,261 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Building2, ChevronLeft, ChevronRight, Landmark, Loader2, Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
fechamentoBancoPontosService,
type BancoPontosEstaAtivoFiltro,
type BancoPontosSaldoItem,
} from "@/services/fechamento/bancoPontos";
import { resolveParceiroFotoUrl } from "@/services/fechamento/parceiroFotoUrl";
const PER_PAGE = 20;
function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function iniciais(nome: string): string {
const p = nome.trim().split(/\s+/).slice(0, 2);
return p.map((w) => w[0]?.toUpperCase() ?? "").join("") || "?";
}
function getDisplayNome(row: BancoPontosSaldoItem): string {
if (row.codinome?.trim()) {
return `${row.nome} (${row.codinome.trim()})`;
}
return row.nome;
}
function ParceiroFotoCell({
nome,
logoUrl,
fotoUrl,
}: {
nome: string;
logoUrl: string | null;
fotoUrl?: string | null;
}) {
const [imgErro, setImgErro] = useState(false);
const src = resolveParceiroFotoUrl(fotoUrl ?? logoUrl);
useEffect(() => {
setImgErro(false);
}, [src]);
const classes =
"h-12 w-12 rounded-lg border border-border object-cover";
if (!src || imgErro) {
return (
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted text-xs font-semibold text-muted-foreground">
{iniciais(nome)}
</div>
);
}
return (
<img
src={src}
alt=""
className={classes}
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setImgErro(true)}
/>
);
}
export default function BancoPontos() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [rows, setRows] = useState<BancoPontosSaldoItem[]>([]);
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
const [buscaInput, setBuscaInput] = useState("");
const [buscaDebounced, setBuscaDebounced] = useState("");
const [estaAtivo, setEstaAtivo] = useState<BancoPontosEstaAtivoFiltro>("all");
const [page, setPage] = useState(1);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await fechamentoBancoPontosService.listarSaldos({
estaAtivo,
busca: buscaDebounced.trim() || undefined,
page,
perPage: PER_PAGE,
});
setRows(res.data ?? []);
setMeta(res.meta);
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao carregar banco de pontos.";
toast.error(msg);
setRows([]);
} finally {
setLoading(false);
}
}, [estaAtivo, buscaDebounced, page]);
useEffect(() => {
const t = window.setTimeout(() => setBuscaDebounced(buscaInput), 350);
return () => window.clearTimeout(t);
}, [buscaInput]);
useEffect(() => {
setPage(1);
}, [estaAtivo, buscaDebounced]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
Banco de Pontos
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Lista de parceiros e saldo; use Detalhes para ver o extrato completo.
</p>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-3 md:p-6">
<div className="flex flex-col gap-3 rounded-lg border border-border/80 bg-card/50 p-4 md:flex-row md:flex-wrap md:items-end">
<div className="min-w-[200px] flex-1 space-y-2">
<Label htmlFor="banco-busca">Buscar parceiro</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="banco-busca"
className="pl-9"
placeholder="Nome ou codinome..."
value={buscaInput}
onChange={(e) => setBuscaInput(e.target.value)}
/>
</div>
</div>
<div className="w-full space-y-2 md:w-48">
<Label>Status</Label>
<Select value={estaAtivo} onValueChange={(v) => setEstaAtivo(v as BancoPontosEstaAtivoFiltro)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="true">Ativos</SelectItem>
<SelectItem value="false">Inativos</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[88px]">Foto</TableHead>
<TableHead className="min-w-[220px]">Nome</TableHead>
<TableHead className="min-w-[120px] text-right">Saldo</TableHead>
<TableHead className="w-[140px] text-center">Detalhes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="py-14 text-center text-muted-foreground">
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
Carregando parceiros...
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
<Building2 className="mx-auto mb-2 h-10 w-10 opacity-40" />
Nenhum parceiro encontrado com os filtros atuais.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.parceiroId}>
<TableCell>
<ParceiroFotoCell
key={row.parceiroId}
nome={row.nome}
logoUrl={row.logoUrl}
fotoUrl={row.fotoUrl}
/>
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell
className={`text-right font-semibold tabular-nums ${
Number(row.saldo) >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{formatPontos(Number(row.saldo))}
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="outline"
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
onClick={() =>
navigate(`/intelligence-score/banco-pontos/${row.parceiroId}`, {
state: { nomeExibicao: getDisplayNome(row) },
})
}
>
Detalhes
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loading && meta.totalPaginas > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
<p className="text-sm text-muted-foreground">
Página {meta.paginaAtual} de {meta.totalPaginas}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= meta.totalPaginas}
onClick={() => setPage((p) => Math.min(meta.totalPaginas, p + 1))}
>
Próxima
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,446 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
Landmark,
Loader2,
Trash2,
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import {
fechamentoBancoPontosService,
type BancoPontosExtratoItem,
type BancoPontosParceiroExtrato,
} from "@/services/fechamento/bancoPontos";
const PER_PAGE = 20;
function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function formatData(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
/** Competência do fechamento (mês/ano), quando a API envia `competenciaMes` e `competenciaAno`. */
function formatCompetenciaMesAno(mes: number | null, ano: number | null): string {
if (mes == null || ano == null) return "—";
const m = Math.trunc(Number(mes));
const y = Math.trunc(Number(ano));
if (!Number.isFinite(m) || !Number.isFinite(y) || m < 1 || m > 12) return "—";
return `${String(m).padStart(2, "0")}/${y}`;
}
type ExtratoLocationState = { nomeExibicao?: string };
function nomeExibicaoParceiro(p: BancoPontosParceiroExtrato): string {
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
}
export default function BancoPontosExtrato() {
const navigate = useNavigate();
const location = useLocation();
const { me } = useAuthAccess();
const podeExcluirTransacao = me?.capabilities.bancoPontos.excluirTransacao ?? false;
const { parceiroId = "" } = useParams();
const nomeDoState = (location.state as ExtratoLocationState | null)?.nomeExibicao?.trim();
const [loading, setLoading] = useState(true);
const [exportando, setExportando] = useState(false);
const [parceiro, setParceiro] = useState<BancoPontosParceiroExtrato | null>(null);
const [linhas, setLinhas] = useState<BancoPontosExtratoItem[]>([]);
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
const [page, setPage] = useState(1);
const [linhaParaExcluir, setLinhaParaExcluir] = useState<BancoPontosExtratoItem | null>(null);
const [motivoExclusao, setMotivoExclusao] = useState("");
const [excluindo, setExcluindo] = useState(false);
const load = useCallback(async () => {
if (!parceiroId) return;
try {
setLoading(true);
const res = await fechamentoBancoPontosService.listarExtrato(parceiroId, {
page,
perPage: PER_PAGE,
});
setParceiro(res.parceiro);
setLinhas(res.data ?? []);
setMeta(res.meta);
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao carregar extrato.";
toast.error(msg);
setParceiro(null);
setLinhas([]);
} finally {
setLoading(false);
}
}, [parceiroId, page]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setPage(1);
setMeta({ total: 0, paginaAtual: 1, totalPaginas: 1 });
}, [parceiroId]);
const tituloParceiro = parceiro
? nomeExibicaoParceiro(parceiro)
: nomeDoState ?? (loading ? "Carregando…" : "Parceiro");
const saldoAtual = parceiro != null ? Number(parceiro.saldo ?? 0) : null;
const saldoFormatado =
parceiro != null && saldoAtual !== null && Number.isFinite(saldoAtual) ? formatPontos(saldoAtual) : null;
const linhasOrdenadas = useMemo(
() =>
[...linhas].sort((a, b) => {
const timeA = new Date(a.criadoEm).getTime();
const timeB = new Date(b.criadoEm).getTime();
if (Number.isNaN(timeA) || Number.isNaN(timeB)) {
return String(a.criadoEm).localeCompare(String(b.criadoEm));
}
return timeA - timeB;
}),
[linhas],
);
const intervaloLabel = (() => {
if (meta.total === 0) return "Mostrando 0 de 0";
const inicio = (page - 1) * PER_PAGE + 1;
const fim = Math.min(page * PER_PAGE, meta.total);
return `Mostrando ${inicio}${fim} de ${meta.total}`;
})();
const handleExcluirTransacao = async () => {
if (!linhaParaExcluir) return;
const motivo = motivoExclusao.trim();
if (motivo.length < 3) {
toast.error("Informe o motivo da exclusão (mínimo 3 caracteres).");
return;
}
try {
setExcluindo(true);
await fechamentoBancoPontosService.excluirTransacao(linhaParaExcluir.id, motivo);
toast.success("Lançamento excluído do extrato.");
setLinhaParaExcluir(null);
setMotivoExclusao("");
await load();
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao excluir lançamento.";
toast.error(msg);
} finally {
setExcluindo(false);
}
};
const handleExportarXlsx = async () => {
if (!parceiroId) return;
try {
setExportando(true);
const { buffer, filename } = await fechamentoBancoPontosService.exportarExtrato(parceiroId);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `banco-pontos-${parceiroId}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Planilha exportada com sucesso.");
} catch (e) {
const msg = e instanceof Error ? e.message : "Erro ao exportar extrato.";
toast.error(msg);
} finally {
setExportando(false);
}
};
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div className="mb-3">
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/banco-pontos")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar ao banco de pontos
</Button>
</div>
<div className="flex flex-col gap-4">
<div className="min-w-0">
<h1 className="flex flex-wrap items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
<span className="truncate">Extrato {tituloParceiro}</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Histórico de créditos e débitos (mais antigos primeiro).
</p>
{loading ? (
<p className="mt-3 text-sm text-muted-foreground">Carregando saldo atual</p>
) : parceiro != null && saldoFormatado != null ? (
<div className="mt-3 flex flex-wrap items-baseline gap-2 rounded-lg border border-border/80 bg-muted/30 px-3 py-2 md:px-4 md:py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Wallet className="h-4 w-4 shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide">Saldo atual</span>
</div>
<span
className={`text-2xl font-bold tabular-nums md:text-3xl ${
(saldoAtual ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{saldoFormatado}
</span>
<span className="text-xs text-muted-foreground">pontos</span>
</div>
) : null}
</div>
</div>
</div>
<div className="flex-1 space-y-4 p-3 md:p-6">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
disabled={!parceiroId || exportando}
onClick={() => void handleExportarXlsx()}
>
{exportando ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Exportar XLSX
</Button>
</div>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[150px]">Data</TableHead>
<TableHead className="min-w-[110px] whitespace-nowrap">Competência</TableHead>
<TableHead className="min-w-[280px]">Descrição</TableHead>
<TableHead className="min-w-[100px]">Tipo</TableHead>
<TableHead className="min-w-[130px] text-right">Valor (Pontos)</TableHead>
<TableHead className="min-w-[120px] text-center">Fechamento</TableHead>
{podeExcluirTransacao ? (
<TableHead className="min-w-[80px] text-center">Ações</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={podeExcluirTransacao ? 7 : 6}
className="py-14 text-center text-muted-foreground"
>
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
Carregando extrato...
</TableCell>
</TableRow>
) : linhasOrdenadas.length === 0 ? (
<TableRow>
<TableCell
colSpan={podeExcluirTransacao ? 7 : 6}
className="py-12 text-center text-muted-foreground"
>
Nenhuma movimentação registrada para este parceiro.
</TableCell>
</TableRow>
) : (
linhasOrdenadas.map((linha) => {
const isCredito = linha.tipo === "credito";
const q = Number(linha.quantidade);
return (
<TableRow key={linha.id}>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatData(linha.criadoEm)}
</TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
</TableCell>
<TableCell className="max-w-[420px] text-sm font-medium leading-snug">{linha.descricao}</TableCell>
<TableCell>
{isCredito ? (
<Badge className="border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200">
Crédito
</Badge>
) : (
<Badge className="border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50 dark:bg-red-950/40 dark:text-red-200">
Débito
</Badge>
)}
</TableCell>
<TableCell
className={`text-right font-semibold tabular-nums ${
isCredito ? "text-emerald-600" : "text-red-600"
}`}
>
{isCredito ? "+" : ""}
{formatPontos(q)}
</TableCell>
<TableCell className="text-center">
{linha.fechamentoId ? (
<Button
size="sm"
variant="ghost"
className="gap-1 text-foreground hover:bg-zinc-200 hover:text-foreground dark:hover:bg-zinc-700"
onClick={() =>
navigate(`/intelligence-score/fechamentos/${linha.fechamentoId}`, {
state: { competenciaId: undefined, status: undefined },
})
}
>
<ExternalLink className="h-4 w-4" />
Abrir
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</TableCell>
{podeExcluirTransacao ? (
<TableCell className="text-center">
<Button
type="button"
variant="ghost"
size="icon"
title="Excluir lançamento"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setLinhaParaExcluir(linha);
setMotivoExclusao("");
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
) : null}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<p className="text-sm text-muted-foreground">
{loading ? (
"Carregando paginação…"
) : (
<>
{intervaloLabel}
{" · "}
Página {meta.paginaAtual} de{" "}
{meta.total === 0 ? 1 : Math.max(1, meta.totalPaginas)}
</>
)}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={loading || page >= meta.totalPaginas || meta.totalPaginas < 1}
onClick={() => setPage((p) => Math.min(Math.max(1, meta.totalPaginas), p + 1))}
>
Próxima
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<AlertDialog
open={linhaParaExcluir != null}
onOpenChange={(open) => {
if (!open) {
setLinhaParaExcluir(null);
setMotivoExclusao("");
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir lançamento</AlertDialogTitle>
<AlertDialogDescription>
O lançamento será removido do extrato e do saldo, mas permanece registrado no banco com motivo e
auditoria. Esta ação não pode ser desfeita pela interface.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="motivo-exclusao-banco">Motivo da exclusão</Label>
<Textarea
id="motivo-exclusao-banco"
value={motivoExclusao}
onChange={(e) => setMotivoExclusao(e.target.value)}
placeholder="Descreva o motivo (mínimo 3 caracteres)"
rows={3}
disabled={excluindo}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={excluindo}>Cancelar</AlertDialogCancel>
<AlertDialogAction
disabled={excluindo || motivoExclusao.trim().length < 3}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(e) => {
e.preventDefault();
void handleExcluirTransacao();
}}
>
{excluindo ? "Excluindo..." : "Confirmar exclusão"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,866 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
Download,
ExternalLink,
FileSpreadsheet,
FileText,
FolderKanban,
Loader2,
RefreshCcw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
if (row.parceiroCodinome?.trim()) {
return `${row.parceiroNome} (${row.parceiroCodinome.trim()})`;
}
return row.parceiroNome;
}
/** Pontuação nas colunas da tabela: sempre centésimos (pt-BR). */
function formatPontuacaoDecimal(valor: number): string {
if (!Number.isFinite(valor)) return "—";
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function AsanaImportLoadingCard() {
return (
<div
className="flex flex-col items-center gap-6 py-10 text-center"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-20 w-20 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<Download className="relative h-8 w-8 text-primary" aria-hidden />
</div>
<div className="max-w-sm space-y-2">
<p className="text-lg font-semibold text-foreground">Sincronizando com o Asana</p>
<p className="text-sm text-muted-foreground">
Buscando tarefas concluídas no período da competência e montando os fechamentos. Pode levar um minuto
não feche esta página.
</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-2 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[85%] animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[60%] animate-pulse rounded-full bg-muted" />
</div>
</div>
);
}
function reprocessamentoLabel(modo: "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"): string {
if (modo === "reprocessar_tudo") return "Reimportando toda a competência a partir do Asana.";
if (modo === "reprocessar_alguns") return "Atualizando os parceiros selecionados no Asana.";
return "Buscando novos fechamentos no Asana.";
}
/** Supervisor só exporta fechamento do parceiro ao qual está vinculado. */
function supervisorPodeExportarEsteFechamento(
papel: string | null | undefined,
meParceiroId: string | null | undefined,
fechamentoParceiroId: string,
): boolean {
if (papel !== "supervisor") return true;
return Boolean(meParceiroId && meParceiroId === fechamentoParceiroId);
}
export default function CompetenciaFechamentos() {
const { me, papel } = useAuthAccess();
const caps = me?.capabilities;
const podeImportarAsana = caps?.competencias.importarAsana ?? false;
const podeReabrirCompetencia = caps?.competencias.reabrir ?? false;
const podeConcluirCompetencia = caps?.competencias.concluir ?? false;
const podeExportarPlanilha = caps?.fechamentos.exportarPlanilha ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const podeExcluirFechamento = caps?.fechamentos.excluir ?? false;
const { id: competenciaId = "" } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
/** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */
const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null);
const importing = importKind !== null;
const [exportacao, setExportacao] = useState<{ fechamentoId: string; tipo: "planilha" | "pdf" } | null>(null);
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false);
const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false);
const [concluindoCompetencia, setConcluindoCompetencia] = useState(false);
const [reabrindoCompetencia, setReabrindoCompetencia] = useState(false);
const [motivoReabertura, setMotivoReabertura] = useState("");
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
const [reprocessMode, setReprocessMode] = useState<
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
>("reprocessar_tudo");
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
const [fechamentoExcluir, setFechamentoExcluir] = useState<FechamentoDaCompetenciaItem | null>(null);
const [motivoExclusaoFechamento, setMotivoExclusaoFechamento] = useState("");
const [excluindoFechamento, setExcluindoFechamento] = useState(false);
const loadFechamentos = async () => {
try {
setLoading(true);
const [data, competencias] = await Promise.all([
fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId),
fechamentoCompetenciasService.listarCompetencias({}),
]);
const competenciaAtual = competencias.find((item) => item.id === competenciaId);
setFechamentos(data);
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
} catch (error) {
const message =
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
toast.error(message);
setFechamentos([]);
} finally {
setLoading(false);
}
};
const handleImportarAsana = async () => {
setImportKind("sheet");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
toast.success(
`Importação concluída: ${resultado.tarefasImportadas} tasks, ${resultado.fechamentosCriados} fechamentos criados.`,
);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
toast.error(message);
} finally {
setImportKind(null);
}
};
const handleExecutarReprocessamento = async () => {
if (reprocessMode === "reprocessar_alguns" && selectedParceiroIds.length === 0) {
toast.error("Selecione ao menos um fechamento/parceiro para reprocessar.");
return;
}
setImportKind("modal");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
modo: reprocessMode,
parceiroIds: reprocessMode === "reprocessar_alguns" ? selectedParceiroIds : undefined,
});
if (reprocessMode === "buscar_novos_fechamentos") {
toast.success(
`Busca concluída: ${resultado.fechamentosCriados} novos fechamento(s) criado(s), sem alterar os atuais.`,
);
} else {
toast.success(
`Reprocessamento concluído: ${resultado.tarefasImportadas} tasks processadas e ${resultado.fechamentosCriados} fechamento(s) criado(s).`,
);
}
setIsReprocessModalOpen(false);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
toast.error(message);
} finally {
setImportKind(null);
}
};
const handleConcluirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para concluir a competência.");
return;
}
try {
setConcluindoCompetencia(true);
await fechamentoCompetenciasService.concluirCompetencia(competenciaId, me.id);
toast.success("Competência concluída com sucesso.");
setIsConcluirModalOpen(false);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir competência.";
toast.error(message);
} finally {
setConcluindoCompetencia(false);
}
};
const handleReabrirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para reabrir a competência.");
return;
}
if (!motivoReabertura.trim()) {
toast.error("Informe o motivo da reabertura da competência.");
return;
}
try {
setReabrindoCompetencia(true);
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim());
toast.success("Competência reaberta com sucesso.");
setIsReabrirModalOpen(false);
setMotivoReabertura("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao reabrir competência.";
toast.error(message);
} finally {
setReabrindoCompetencia(false);
}
};
const handleExportar = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "planilha" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Planilha exportada com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
toast.error(message);
} finally {
setExportacao(null);
}
};
const handleExportarPdf = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "pdf" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportacao(null);
}
};
const handleConfirmarExclusaoFechamento = async () => {
if (!fechamentoExcluir || !me?.id) {
toast.error("Não foi possível identificar o usuário ou o fechamento.");
return;
}
if (!motivoExclusaoFechamento.trim()) {
toast.error("Informe o motivo da exclusão.");
return;
}
setExcluindoFechamento(true);
try {
await fechamentoFechamentosService.excluirFechamento(fechamentoExcluir.id, {
excluidoPorId: me.id,
motivo: motivoExclusaoFechamento.trim(),
});
toast.success("Fechamento excluído com sucesso.");
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao excluir fechamento.";
toast.error(message);
} finally {
setExcluindoFechamento(false);
}
};
useEffect(() => {
let cancelled = false;
if (competenciaId) {
void loadFechamentos();
}
return () => {
cancelled = true;
};
}, [competenciaId]);
useEffect(() => {
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
}, [fechamentos]);
const temFechamentos = fechamentos.length > 0;
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/gerenciar-fechamentos")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos da Competência
</h1>
{!loading && (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
{competenciaStatus === "concluido" ? (
podeReabrirCompetencia ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirModalOpen(true)}
disabled={reabrindoCompetencia}
>
{reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
</Button>
) : null
) : temFechamentos ? (
<>
{podeImportarAsana ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReprocessModalOpen(true)}
disabled={importing}
className="hover:bg-muted/60 hover:text-foreground"
>
<RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
) : null}
{podeConcluirCompetencia ? (
<Button
size="sm"
variant="default"
onClick={() => setIsConcluirModalOpen(true)}
disabled={concluindoCompetencia}
>
{concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
</Button>
) : null}
</>
) : null}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && fechamentos.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl border-border/80 shadow-sm">
<CardHeader>
<CardTitle>Nenhum fechamento encontrado</CardTitle>
<CardDescription>
Importe as tasks do Asana para criar os fechamentos automaticamente.
</CardDescription>
</CardHeader>
<div className="px-6 pb-6">
{importKind === "sheet" ? (
<AsanaImportLoadingCard />
) : podeImportarAsana ? (
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" />
Importar tasks do Asana
</Button>
) : (
<p className="text-sm text-muted-foreground">
Não permissão para importar do Asana neste perfil. Peça a um administrador.
</p>
)}
</div>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px]">Logo</TableHead>
<TableHead className="min-w-[280px]">Nome</TableHead>
<TableHead
className="min-w-[140px] text-right"
title="Soma de todas as tarefas do fechamento (revisadas ou não), igual ao total do mês no detalhe e ao dashboard."
>
Pontuação Total
</TableHead>
<TableHead className="min-w-[150px] text-right">Pontuação Paga</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="py-16">
<div
className="flex flex-col items-center justify-center gap-4 text-center"
role="status"
aria-live="polite"
>
<Loader2 className="h-9 w-9 animate-spin text-primary" aria-hidden />
<div>
<p className="font-medium text-foreground">Carregando fechamentos</p>
<p className="mt-1 text-sm text-muted-foreground">Aguarde um instante.</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
fechamentos.map((row) => (
<TableRow key={row.id}>
<TableCell>
{row.parceiroLogoUrl ? (
<img
src={row.parceiroLogoUrl}
alt={`Logo ${row.parceiroNome}`}
className="h-8 w-8 rounded-md object-cover"
/>
) : (
"—"
)}
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell className="text-right tabular-nums">
{formatPontuacaoDecimal(Number(row.pontuacaoTotalEntregue))}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{row.status === "fechado"
? formatPontuacaoDecimal(Number(row.pontuacaoPaga))
: "—"}
</TableCell>
<TableCell>
<Badge
className={
row.status === "fechado"
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-slate-500/25 bg-slate-100 font-normal text-slate-700 hover:bg-slate-100 dark:bg-slate-800/60 dark:text-slate-200"
}
>
{row.status === "fechado" ? "Fechado" : "Em aberto"}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-center gap-2">
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportar(row.id)}
disabled={
!podeExportarPlanilha ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar planilha financeira (XLSX)"
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha"
? "Exportando..."
: "Excel"}
</Button>
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportarPdf(row.id)}
disabled={
!podeExportarPdf ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar documento do fechamento (PDF)"
>
<FileText className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf"
? "Exportando..."
: "PDF"}
</Button>
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() =>
navigate(`/intelligence-score/fechamentos/${row.id}`, {
state: { competenciaId, status: row.status },
})
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Acessar
</Button>
{podeExcluirFechamento ? (
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setMotivoExclusaoFechamento("");
setFechamentoExcluir(row);
}}
title="Excluir fechamento"
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog
open={isReprocessModalOpen}
onOpenChange={(open) => {
if (!open && importing) return;
setIsReprocessModalOpen(open);
}}
>
<DialogContent
className="flex min-h-0 max-h-[85vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:w-full"
hideClose={importing}
onPointerDownOutside={(e) => {
if (importing) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (importing) e.preventDefault();
}}
>
{importKind === "modal" ? (
<div
className="absolute inset-0 z-[60] flex flex-col items-center justify-center gap-4 rounded-b-lg rounded-t-lg bg-background/90 p-6 text-center backdrop-blur-sm sm:rounded-lg"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-16 w-16 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<RefreshCcw className="relative h-7 w-7 text-primary" aria-hidden />
</div>
<div className="max-w-md space-y-2">
<p className="text-lg font-semibold text-foreground">Processando no Asana</p>
<p className="text-sm text-muted-foreground">{reprocessamentoLabel(reprocessMode)}</p>
<p className="text-xs text-muted-foreground">Não feche esta janela até a operação terminar.</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-1.5 w-[75%] animate-pulse rounded-full bg-muted" />
</div>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col gap-4 px-6 pb-0 pt-12 sm:pt-14">
<DialogHeader className="shrink-0 space-y-1.5 pr-8 text-left sm:pr-0">
<DialogTitle>Reprocessar Asana</DialogTitle>
<DialogDescription>
Escolha uma estratégia de reprocessamento para esta competência.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4 pr-1">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => setReprocessMode("reprocessar_tudo")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "reprocessar_tudo"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Reprocessar tudo</p>
<p className="mt-1 text-xs text-muted-foreground">
Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
</p>
</button>
<button
type="button"
onClick={() => setReprocessMode("reprocessar_alguns")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "reprocessar_alguns"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Reprocessar apenas alguns</p>
<p className="mt-1 text-xs text-muted-foreground">
Reprocessa somente os parceiros selecionados abaixo.
</p>
</button>
<button
type="button"
onClick={() => setReprocessMode("buscar_novos_fechamentos")}
className={`rounded-lg border p-3 text-left transition ${
reprocessMode === "buscar_novos_fechamentos"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/60"
}`}
>
<p className="text-sm font-semibold">Buscar novos fechamentos</p>
<p className="mt-1 text-xs text-muted-foreground">
Busca no Asana e cria fechamentos que ainda não existem (inclusive vazios, se o parceiro não tiver tasks no período).
</p>
</button>
</div>
{reprocessMode === "reprocessar_alguns" ? (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">Selecione os fechamentos/parceiros</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
>
Marcar todos
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds([])}
>
Limpar
</Button>
</div>
</div>
{fechamentos.map((f) => {
const checked = selectedParceiroIds.includes(f.parceiroId);
return (
<label
key={f.id}
htmlFor={`sel-${f.id}`}
className={`flex cursor-pointer items-center space-x-3 rounded-md border p-2 transition ${
checked ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
>
<Checkbox
id={`sel-${f.id}`}
checked={checked}
onCheckedChange={(value) => {
const on = Boolean(value);
setSelectedParceiroIds((prev) =>
on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
);
}}
/>
<Label htmlFor={`sel-${f.id}`} className="cursor-pointer text-sm">
{getDisplayNome(f)}
<span className="ml-2 text-xs text-muted-foreground">
{f.status === "fechado" ? "Fechado" : "Em aberto"}
</span>
</Label>
</label>
);
})}
</div>
) : null}
</div>
</div>
<DialogFooter className="shrink-0 gap-2 border-t bg-background p-4 sm:justify-end">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReprocessModalOpen(false)}
disabled={importing}
>
Cancelar
</Button>
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
Processando...
</>
) : (
"Executar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReabrirModalOpen} onOpenChange={setIsReabrirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reabrir competência</DialogTitle>
<DialogDescription>
Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabrir-competencia">Motivo *</Label>
<Input
id="motivo-reabrir-competencia"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: correção de ajustes pós-fechamento"
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReabrirModalOpen(false)}
disabled={reabrindoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleReabrirCompetencia()}
disabled={reabrindoCompetencia || !motivoReabertura.trim()}
>
{reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isConcluirModalOpen} onOpenChange={setIsConcluirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Concluir competência</DialogTitle>
<DialogDescription>
Deseja concluir esta competência? A operação será permitida se todos os fechamentos estiverem fechados.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsConcluirModalOpen(false)}
disabled={concluindoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleConcluirCompetencia()}
disabled={concluindoCompetencia}
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
>
{concluindoCompetencia ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Confirmar conclusão"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={fechamentoExcluir !== null}
onOpenChange={(open) => {
if (!open) {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Excluir fechamento</DialogTitle>
<DialogDescription>
{fechamentoExcluir ? (
<>
Parceiro: <span className="font-medium text-foreground">{getDisplayNome(fechamentoExcluir)}</span>.
{fechamentoExcluir.status === "fechado" ? (
<>
{" "}
Será aplicado estorno no banco de pontos (como na reabertura). Se a competência estiver
concluída, reabra a competência antes de excluir este fechamento.
</>
) : null}
</>
) : null}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-excluir-fechamento">Motivo *</Label>
<Textarea
id="motivo-excluir-fechamento"
value={motivoExclusaoFechamento}
onChange={(e) => setMotivoExclusaoFechamento(e.target.value)}
placeholder="Ex.: fechamento criado por engano"
rows={3}
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}}
disabled={excluindoFechamento}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirmarExclusaoFechamento()}
disabled={excluindoFechamento || !motivoExclusaoFechamento.trim()}
>
{excluindoFechamento ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,773 @@
import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
import { Separator } from "@/components/ui/separator";
import { useNavigate } from "react-router-dom";
import { Eye, EyeOff, Image as ImageIcon, Loader2, RefreshCw, Save, Upload } from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MagicCard } from "@/components/ui/magic-card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { asanaService, type AsanaWorkspace } from "@/services/asana";
import {
fechamentoConfiguracoesService,
type ConfiguracaoPublica,
} from "@/services/fechamento/configuracoes";
import { fechamentoUnidadesService } from "@/services/fechamento/unidades";
import {
clearCommanderUnidadeIdCache,
getEstabelecimentoCodigoFromTransfer,
lookupUnidadeByEstabelecimento,
type UnidadeLookupRow,
} from "@/services/fechamento/unidadeContext";
import { fechamentoUploadsService } from "@/services/fechamento/uploads";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
const MAX_EMPRESA_LOGO_BYTES = 5 * 1024 * 1024;
const ALLOWED_EMPRESA_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
function validateEmpresaLogoFile(file: File): string | null {
if (!ALLOWED_EMPRESA_LOGO_TYPES.has(file.type)) {
return "Arquivo inválido. Use JPG, PNG ou WEBP.";
}
if (file.size > MAX_EMPRESA_LOGO_BYTES) {
return "Arquivo excede 5MB. Escolha uma imagem menor.";
}
return null;
}
export default function Configuracoes() {
const navigate = useNavigate();
const { papel, me } = useAuthAccess();
const caps = me?.capabilities;
const podeSalvarConfig = caps?.configuracoes.salvar ?? false;
const isAdmin = papel === "admin";
const unidadeSectionRef = useRef<HTMLDivElement>(null);
const empresaLogoFileInputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(true);
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [savingEmpresaDoc, setSavingEmpresaDoc] = useState(false);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
const [showToken, setShowToken] = useState(false);
const [configAtual, setConfigAtual] = useState<ConfiguracaoPublica | null>(null);
const [asanaToken, setAsanaToken] = useState("");
const [workspaces, setWorkspaces] = useState<AsanaWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
const [empresaRazaoSocial, setEmpresaRazaoSocial] = useState("");
const [empresaCnpj, setEmpresaCnpj] = useState("");
const [empresaLogoUrl, setEmpresaLogoUrl] = useState("");
const [selectedEmpresaLogoFile, setSelectedEmpresaLogoFile] = useState<File | null>(null);
const [empresaLogoPreviewUrl, setEmpresaLogoPreviewUrl] = useState<string | null>(null);
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null);
const [nomeUnidade, setNomeUnidade] = useState("");
const [loadingUnidade, setLoadingUnidade] = useState(true);
const [savingUnidade, setSavingUnidade] = useState(false);
const tokenJaConfigurado = Boolean(configAtual?.asanaTokenConfigured);
const tokenDigitado = asanaToken.trim();
const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
const empresaLogoDisplayUrl = empresaLogoPreviewUrl ?? (empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null);
const empresaLogoStatusLabel = useMemo(() => {
if (savingEmpresaDoc && selectedEmpresaLogoFile) {
return "Enviando logo e salvando…";
}
if (selectedEmpresaLogoFile) {
return `Arquivo selecionado: ${selectedEmpresaLogoFile.name}`;
}
return "Nenhum arquivo novo selecionado.";
}, [savingEmpresaDoc, selectedEmpresaLogoFile]);
const workspaceOptions = useMemo(() => {
if (!selectedWorkspaceId || !selectedWorkspaceNome) {
return workspaces;
}
if (workspaces.some((item) => item.id === selectedWorkspaceId)) {
return workspaces;
}
return [{ id: selectedWorkspaceId, name: selectedWorkspaceNome }, ...workspaces];
}, [workspaces, selectedWorkspaceId, selectedWorkspaceNome]);
const loadUnidade = async () => {
try {
setLoadingUnidade(true);
const codigo = getEstabelecimentoCodigoFromTransfer();
setCodigoEstabelecimento(codigo);
if (!codigo) {
setUnidadeExistente(null);
setNomeUnidade("");
return;
}
const row = await lookupUnidadeByEstabelecimento(codigo);
setUnidadeExistente(row);
setNomeUnidade(row?.nome ?? "");
} catch {
setUnidadeExistente(null);
setNomeUnidade("");
} finally {
setLoadingUnidade(false);
}
};
const loadConfiguracoesAsana = async () => {
try {
setConfigAsanaError(null);
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
const data = await fechamentoConfiguracoesService.getConfiguracoes();
setConfigAtual(data);
setAsanaToken(data?.asanaToken ?? "");
setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(data?.empresaRazaoSocial ?? "");
setEmpresaCnpj(data?.empresaCnpj ?? "");
setEmpresaLogoUrl(data?.empresaLogoUrl ?? "");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
setConfigAsanaError(message);
setConfigAtual(null);
setEmpresaRazaoSocial("");
setEmpresaCnpj("");
setEmpresaLogoUrl("");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
}
};
const loadAll = async () => {
try {
setLoading(true);
await Promise.all([loadUnidade(), loadConfiguracoesAsana()]);
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
return () => {
if (empresaLogoPreviewUrl) {
URL.revokeObjectURL(empresaLogoPreviewUrl);
}
};
}, [empresaLogoPreviewUrl]);
useEffect(() => {
if (typeof window === "undefined" || loading) return;
if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
unidadeSectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [loading]);
const handleBuscarWorkspaces = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para alterar a integração Asana.");
return;
}
if (!tokenDigitado) {
toast.error("Informe o token do Asana para buscar workspaces.");
return;
}
try {
setLoadingWorkspaces(true);
const ws = await asanaService.getWorkspaces(tokenDigitado);
setWorkspaces(ws);
if (ws.length === 0) {
toast.warning("Nenhum workspace foi encontrado para este token.");
return;
}
if (selectedWorkspaceId && ws.some((item) => item.id === selectedWorkspaceId)) {
const selected = ws.find((item) => item.id === selectedWorkspaceId);
setSelectedWorkspaceNome(selected?.name ?? selectedWorkspaceNome);
} else {
setSelectedWorkspaceId("");
setSelectedWorkspaceNome("");
}
toast.success(`${ws.length} workspace(s) carregado(s).`);
} catch (error) {
const message = error && typeof error === "object" && "message" in error
? String((error as { message: unknown }).message)
: "Erro ao buscar workspaces do Asana.";
toast.error(message);
setWorkspaces([]);
} finally {
setLoadingWorkspaces(false);
}
};
const handleSaveAsana = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
if (!selectedWorkspaceId) {
toast.error("Selecione um workspace antes de salvar.");
return;
}
try {
setSaving(true);
const workspaceSelecionado = workspaceOptions.find((item) => item.id === selectedWorkspaceId);
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
asanaToken: tokenDigitado ? tokenDigitado : undefined,
asanaWorkspaceId: selectedWorkspaceId,
asanaWorkspaceNome: workspaceSelecionado?.name ?? selectedWorkspaceNome,
});
setConfigAtual(updated);
setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setConfigAsanaError(null);
toast.success("Configurações salvas com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar configurações.";
toast.error(message);
} finally {
setSaving(false);
}
};
const handleEmpresaLogoFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const validationMessage = validateEmpresaLogoFile(file);
if (validationMessage) {
toast.error(validationMessage);
event.currentTarget.value = "";
return;
}
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return URL.createObjectURL(file);
});
setSelectedEmpresaLogoFile(file);
};
const clearEmpresaLogoSelection = () => {
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
setEmpresaLogoUrl("");
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
};
const handleSalvarEmpresaDoc = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
try {
setSavingEmpresaDoc(true);
let logoUrlParaSalvar: string | null = null;
if (selectedEmpresaLogoFile) {
const signed = await fechamentoUploadsService.presignUploadEmpresaLogo({
fileName: selectedEmpresaLogoFile.name,
contentType: selectedEmpresaLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
});
await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedEmpresaLogoFile);
logoUrlParaSalvar = signed.publicUrl;
} else {
logoUrlParaSalvar = empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null;
}
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
empresaRazaoSocial: empresaRazaoSocial.trim() || null,
empresaCnpj: empresaCnpj.trim() || null,
empresaLogoUrl: logoUrlParaSalvar,
});
setConfigAtual(updated);
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setConfigAsanaError(null);
toast.success("Dados da empresa salvos.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar dados da empresa.";
toast.error(message);
} finally {
setSavingEmpresaDoc(false);
}
};
const handleSalvarUnidade = async () => {
const nome = nomeUnidade.trim();
if (!nome) {
toast.error("Informe o nome da unidade.");
return;
}
const codigo = codigoEstabelecimento.trim();
if (!codigo) {
toast.error("Código do estabelecimento não disponível no transfer.");
return;
}
try {
setSavingUnidade(true);
if (unidadeExistente) {
await fechamentoUnidadesService.updateUnidade(unidadeExistente.id, { nome });
toast.success("Unidade atualizada.");
clearCommanderUnidadeIdCache();
await loadUnidade();
} else {
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
toast.success("Unidade cadastrada.");
clearCommanderUnidadeIdCache();
navigate("/intelligence-score", { replace: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
toast.error(message);
} finally {
setSavingUnidade(false);
}
};
if (loading) {
return (
<div className="flex min-h-[300px] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Carregando configurações...</span>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl space-y-8 pb-8">
<header className="border-b border-border pb-6">
<h1 className="text-xl font-semibold tracking-tight text-foreground md:text-2xl">Configurações do sistema</h1>
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto
atual do Commander.
</p>
</header>
<div ref={unidadeSectionRef} id="unidade">
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-0 border-b bg-muted/30 px-6 py-4">
<CardTitle id="titulo-codigo-estabelecimento" className="text-base font-semibold">
Código do estabelecimento
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{loadingUnidade ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Carregando dados da unidade...
</div>
) : !isAdmin ? (
<p className="text-sm text-muted-foreground">
Apenas administradores podem cadastrar ou editar a unidade aqui.
</p>
) : !codigoEstabelecimento ? (
<p className="text-sm text-muted-foreground">
Abra o módulo pelo Commander com o estabelecimento no transfer para exibir o código e cadastrar a
unidade.
</p>
) : (
<>
<div className="space-y-2">
<Input
id="codigo-estabelecimento"
aria-labelledby="titulo-codigo-estabelecimento"
value={codigoEstabelecimento}
readOnly
className="h-11 font-mono text-sm bg-muted"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nome-unidade">Nome da unidade</Label>
<Input
id="nome-unidade"
value={nomeUnidade}
onChange={(e) => setNomeUnidade(e.target.value)}
placeholder="Ex.: Unidade Matriz"
className="h-11 text-sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{unidadeExistente ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Unidade cadastrada
</Badge>
) : (
<Badge variant="outline" className="max-w-full shrink-0 whitespace-normal sm:whitespace-nowrap">
Pendente: informe o nome e salve para criar a unidade
</Badge>
)}
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button type="button" onClick={handleSalvarUnidade} disabled={savingUnidade}>
{savingUnidade ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</MagicCard>
</Card>
</div>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Dados da empresa</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Razão social, CNPJ e logo usados no cabeçalho do PDF de fechamento (estilo invoice). Envie JPG, PNG ou
WEBP de até 5MB.
</CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{configAsanaError ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-950 dark:text-amber-100">
A integração Asana não pôde ser carregada; você ainda pode salvar os dados da empresa para o PDF.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor="empresa-razao">Razão social</Label>
<Input
id="empresa-razao"
value={empresaRazaoSocial}
onChange={(e) => setEmpresaRazaoSocial(e.target.value)}
placeholder="Ex.: Minha Empresa LTDA"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label htmlFor="empresa-cnpj">CNPJ</Label>
<Input
id="empresa-cnpj"
value={empresaCnpj}
onChange={(e) => setEmpresaCnpj(e.target.value)}
placeholder="Somente números ou formatado"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label>Logo da empresa</Label>
<div className="flex flex-col items-start gap-3 rounded-md border border-border bg-muted/20 px-4 py-4 sm:flex-row sm:items-center">
<button
type="button"
className="group relative h-24 w-40 shrink-0 overflow-hidden rounded-md border bg-background shadow-sm transition hover:brightness-95 disabled:pointer-events-none disabled:opacity-50"
onClick={() => empresaLogoFileInputRef.current?.click()}
title="Clique para enviar uma imagem"
disabled={!podeSalvarConfig || savingEmpresaDoc}
>
{empresaLogoDisplayUrl ? (
<img
src={empresaLogoDisplayUrl}
alt="Pré-visualização do logo"
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100">
<span className="inline-flex items-center gap-1 text-xs font-medium">
<Upload className="h-3.5 w-3.5" />
Enviar imagem
</span>
</div>
</button>
<input
ref={empresaLogoFileInputRef}
id="empresa-logo-file"
type="file"
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={handleEmpresaLogoFileChange}
className="hidden"
disabled={!podeSalvarConfig}
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs leading-relaxed text-muted-foreground">{empresaLogoStatusLabel}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={clearEmpresaLogoSelection}
disabled={!podeSalvarConfig || savingEmpresaDoc || (!empresaLogoDisplayUrl && !selectedEmpresaLogoFile)}
>
Remover logo
</Button>
</div>
</div>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
onClick={() => void handleSalvarEmpresaDoc()}
disabled={savingEmpresaDoc || !podeSalvarConfig}
>
{savingEmpresaDoc ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar dados da empresa
</>
)}
</Button>
</div>
</CardContent>
</MagicCard>
</Card>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
</CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader>
<CardContent className="space-y-6 px-6 py-6">
{configAsanaError ? (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<p className="font-medium">Não foi possível carregar as configurações do Asana</p>
<p className="mt-1 leading-relaxed opacity-90">
{configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
</p>
</div>
) : null}
<div className="space-y-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<Label htmlFor="asana-token" className="text-sm font-medium">
Token de acesso
</Label>
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
{tokenJaConfigurado ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Token configurado no servidor
</Badge>
) : (
<Badge variant="outline" className="shrink-0 whitespace-nowrap">
Token ainda não salvo
</Badge>
)}
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="relative min-w-0 flex-1">
<Input
id="asana-token"
type={showToken ? "text" : "password"}
value={asanaToken}
onChange={(e) => setAsanaToken(e.target.value)}
placeholder={
tokenJaConfigurado
? "Substitua o token ou mantenha em branco para não alterar ao salvar"
: "Cole o token do Asana (Personal Access Token)"
}
className="h-11 pr-11 font-mono text-sm"
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Ocultar token" : "Mostrar token"}
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
type="button"
variant="outline"
className="h-11 shrink-0 whitespace-nowrap bg-background px-5 hover:bg-muted/60 hover:text-foreground dark:bg-muted/30 dark:hover:bg-muted/50 lg:self-stretch"
onClick={handleBuscarWorkspaces}
disabled={!podeSalvarConfig || !podeBuscarWorkspaces || Boolean(configAsanaError)}
>
{loadingWorkspaces ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Buscando
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Listar workspaces
</>
)}
</Button>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="asana-workspace" className="text-sm font-medium">
Workspace padrão
</Label>
<Select
value={selectedWorkspaceId}
onValueChange={(value) => {
setSelectedWorkspaceId(value);
const selected = workspaceOptions.find((item) => item.id === value);
setSelectedWorkspaceNome(selected?.name ?? "");
}}
disabled={!podeSalvarConfig || Boolean(configAsanaError)}
>
<SelectTrigger id="asana-workspace" className="h-11 w-full">
<SelectValue
placeholder={
workspaceOptions.length > 0
? "Selecione um workspace"
: "Informe o token e clique em Listar workspaces"
}
/>
</SelectTrigger>
<SelectContent>
{workspaceOptions.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
Selecionado: <span className="font-medium text-foreground">{selectedWorkspaceNome || "—"}</span>
</p>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
size="lg"
className="min-w-[200px]"
onClick={handleSaveAsana}
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError) || !podeSalvarConfig}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar integração Asana
</>
)}
</Button>
</div>
</CardContent>
</MagicCard>
</Card>
</div>
);
}
@@ -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 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. é permitida quando não 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 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 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>
);
}
-89
View File
@@ -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;
+3
View File
@@ -1,5 +1,8 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/** Operações de IA (imagem, áudio, transcrição) podem levar vários minutos. */
export const LONG_RUNNING_REQUEST_TIMEOUT_MS = 300_000;
class ApiService { class ApiService {
private readonly axiosInstance: AxiosInstance; private readonly axiosInstance: AxiosInstance;
private readonly apiKey: string; private readonly apiKey: string;
+3 -2
View File
@@ -1,5 +1,5 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer'; export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
@@ -135,7 +135,8 @@ class AudioGenerationService {
try { try {
const response = await apiService.post<AudioGenerationResponse>( const response = await apiService.post<AudioGenerationResponse>(
this.AUDIO_GENERATION_ENDPOINT, this.AUDIO_GENERATION_ENDPOINT,
{ estabelecimento_id: estabId, user_email: email, message, voice } { estabelecimento_id: estabId, user_email: email, message, voice },
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
); );
return response.data; return response.data;
} catch (error: unknown) { } catch (error: unknown) {
+42
View File
@@ -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();
+345
View File
@@ -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();
+321
View File
@@ -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 (112), 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();
}
+42
View File
@@ -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;
}
+222
View File
@@ -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();
+89
View File
@@ -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;
}
+419
View File
@@ -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}`;
}
+159
View File
@@ -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();
+106
View File
@@ -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;
}
+79
View File
@@ -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();
+89
View File
@@ -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();
+142
View File
@@ -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();
+3 -2
View File
@@ -1,5 +1,5 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024'; export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
@@ -127,7 +127,8 @@ class ImageGenerationService {
try { try {
const response = await apiService.post<ImageGenerationResponse>( const response = await apiService.post<ImageGenerationResponse>(
this.IMAGE_GENERATION_ENDPOINT, this.IMAGE_GENERATION_ENDPOINT,
{ estabelecimento_id: estabId, user_email: email, description, size } { estabelecimento_id: estabId, user_email: email, description, size },
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
); );
if (!response.data.success) { if (!response.data.success) {
+3 -2
View File
@@ -1,5 +1,5 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions'; import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api'; import { apiService, LONG_RUNNING_REQUEST_TIMEOUT_MS } from './api';
export interface TranscriptionResponse { export interface TranscriptionResponse {
success: boolean; success: boolean;
@@ -82,7 +82,8 @@ class TranscriptionService {
try { try {
const response = await apiService.postFormData<TranscriptionResponse>( const response = await apiService.postFormData<TranscriptionResponse>(
this.TRANSCRIPTION_ENDPOINT, this.TRANSCRIPTION_ENDPOINT,
formData formData,
{ timeout: LONG_RUNNING_REQUEST_TIMEOUT_MS }
); );
return response.data; return response.data;
} catch (error: unknown) { } catch (error: unknown) {
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
readonly VITE_API_BASE_URL_COMMANDER: string
readonly VITE_API_KEY: string readonly VITE_API_KEY: string
readonly VITE_USER_EMAIL: string readonly VITE_USER_EMAIL: string
readonly VITE_ESTABELECIMENTO_ID: string readonly VITE_ESTABELECIMENTO_ID: string
+43 -7
View File
@@ -25,6 +25,9 @@ export default {
primary: { primary: {
DEFAULT: "hsl(var(--primary))", DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))", foreground: "hsl(var(--primary-foreground))",
hover: "hsl(var(--primary-hover))",
pressed: "hsl(var(--primary-pressed))",
light: "hsl(var(--primary-light))",
}, },
secondary: { secondary: {
DEFAULT: "hsl(var(--secondary))", DEFAULT: "hsl(var(--secondary))",
@@ -68,11 +71,33 @@ export default {
border: "hsl(var(--sidebar-border))", border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))", ring: "hsl(var(--sidebar-ring))",
}, },
"bubble-bot": "hsl(var(--bubble-bot))",
"nav-active": "hsl(var(--nav-active))",
"input-bg": "hsl(var(--input-bg))",
placeholder: "hsl(var(--placeholder))",
"btn-secondary-border": "hsl(var(--btn-secondary-border))",
"border-subtle": "hsl(var(--border-subtle))",
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", sm: "var(--radius-sm)",
md: "calc(var(--radius) - 2px)", md: "var(--radius-md)",
sm: "calc(var(--radius) - 4px)", lg: "var(--radius-lg)",
xl: "var(--radius-xl)",
"2xl": "20px",
pill: "var(--radius-pill)",
},
boxShadow: {
card: "var(--shadow-card)",
modal: "var(--shadow-modal)",
},
fontSize: {
display: ["32px", { lineHeight: "1.25", fontWeight: "700" }],
h1: ["24px", { lineHeight: "1.25", fontWeight: "600" }],
h2: ["18px", { lineHeight: "1.5", fontWeight: "600" }],
h3: ["15px", { lineHeight: "1.5" }],
body: ["14px", { lineHeight: "1.5" }],
small: ["12px", { lineHeight: "1.5" }],
caption: ["11px", { lineHeight: "1.5" }],
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {
@@ -84,15 +109,25 @@ export default {
to: { height: "0" }, to: { height: "0" },
}, },
"pulse-glow": { "pulse-glow": {
"0%, 100%": { "0%, 100%": {
boxShadow: "0 0 20px hsl(190 100% 50% / 0.3)", boxShadow: "0 0 20px hsl(210 100% 33% / 0.3)",
transform: "scale(1)" transform: "scale(1)"
}, },
"50%": { "50%": {
boxShadow: "0 0 40px hsl(190 100% 50% / 0.5)", boxShadow: "0 0 40px hsl(210 100% 33% / 0.5)",
transform: "scale(1.02)" transform: "scale(1.02)"
}, },
}, },
"online-pulse": {
"0%, 100%": {
boxShadow: "0 0 0 0 hsl(134 61% 41% / 0.7)",
opacity: "1",
},
"50%": {
boxShadow: "0 0 0 4px hsl(134 61% 41% / 0)",
opacity: "0.85",
},
},
"slide-in-right": { "slide-in-right": {
"0%": { transform: "translateX(100%)", opacity: "0" }, "0%": { transform: "translateX(100%)", opacity: "0" },
"100%": { transform: "translateX(0)", opacity: "1" }, "100%": { transform: "translateX(0)", opacity: "1" },
@@ -106,6 +141,7 @@ export default {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
"pulse-glow": "pulse-glow 2s ease-in-out infinite", "pulse-glow": "pulse-glow 2s ease-in-out infinite",
"online-pulse": "online-pulse 1.5s ease-in-out infinite",
"slide-in-right": "slide-in-right 0.3s ease-out", "slide-in-right": "slide-in-right 0.3s ease-out",
"fade-in": "fade-in 0.3s ease-out", "fade-in": "fade-in 0.3s ease-out",
}, },
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.8.3"}