28 Commits

Author SHA1 Message Date
developer1054 39032ea621 Merge commit '71f9c9ab860f7c5597427fb5fc82492a925aa9cf' 2026-01-19 14:27:07 -03:00
Vitex Tecnologia 71f9c9ab86 mudanca requisicoes API do metodo PUT para POST e limpeza / organizacao dos códigos 2026-01-19 14:19:18 -03:00
developer1054 d75d80e9c7 build 2026-01-15 10:08:47 -03:00
developer1054 189a2a8f2c Merge commit '76cbd80eefee58b1335cf335ef42eaa3570c26ae' 2026-01-15 09:38:02 -03:00
developer1054 50aca767d7 remove commented-out code for bots tab in Layout component 2026-01-15 09:37:15 -03:00
Vitex Tecnologia 76cbd80eef modulo de agente pessoal de IA 2026-01-14 20:10:42 -03:00
luisfepsale ee7204982c [change/tempo de requisição do parecer] 2025-11-24 09:26:33 -03:00
luisfepsale a28b780718 [new/parecer juridico] 2025-11-19 15:19:41 -03:00
developer1054 0fa382ef0e Merge branch 'main' of https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API 2025-10-28 11:34:30 -03:00
developer1054 c2aa536660 fix redir 2025-10-28 11:34:25 -03:00
luisfepsale c4b281abd5 [Correçã de CORs nos arquivos] 2025-10-28 11:31:54 -03:00
luisfepsale 45ea53b364 [Parecer Juridico] 2025-10-28 09:56:35 -03:00
luisfepsale 9f75feb69a correções e bugs 2025-10-24 12:05:27 -03:00
luisfepsale a85465e332 Integração com o banco de dados 2025-10-23 23:27:28 -03:00
developer1054 8a9096470f Merge pull request 'develop' (#1) from develop into main
Reviewed-on: https://git.hgtx.com.br/luis.felipe/OPEN_CODEX_API/pulls/1
2025-10-23 17:02:15 -03:00
developer1054 c82c2166f2 Merge commit 'c8c2ca8c5ba14196e2e1892fffd24024e54ac8c3' into develop 2025-10-23 16:59:06 -03:00
developer1054 c8c2ca8c5b integracao-core 2025-10-23 16:58:21 -03:00
developer1054 8b5c940976 rotas 2025-10-23 15:30:48 -03:00
luisfepsale 235fb538e1 Primeiro Commit 2025-10-22 12:52:23 -03:00
gpt-engineer-app[bot] 27820254e7 Use Axios for POST request 2025-10-17 21:32:50 +00:00
gpt-engineer-app[bot] e9f0165006 Refactor Opinion Agent to use POST request 2025-10-17 21:30:37 +00:00
gpt-engineer-app[bot] c88342369f Update audio transcription icon 2025-10-17 18:51:07 +00:00
gpt-engineer-app[bot] 18285851ac Refactor audio section in sidebar 2025-10-17 18:49:40 +00:00
gpt-engineer-app[bot] b1b37f5cf1 Refactor: Make UI responsive 2025-10-17 18:41:54 +00:00
gpt-engineer-app[bot] b81e1cf47d Code edited in Lovable Code Editor 2025-10-17 18:32:40 +00:00
gpt-engineer-app[bot] 9ab3530a80 Refactor: Change opinion agent icon 2025-10-17 18:27:52 +00:00
gpt-engineer-app[bot] c37c854b07 feat: Add pagination to opinion search 2025-10-17 17:22:30 +00:00
gpt-engineer-app[bot] b0a1cfdab5 Refactor Agent View 2025-10-17 16:31:41 +00:00
62 changed files with 9936 additions and 975 deletions
+20
View File
@@ -0,0 +1,20 @@
# API Configuration
# Base URL da API do HGTX
VITE_API_BASE_URL=https://prod-hgtx-intelligence-n8n.hgtx.com.br
VITE_BASE_URL_OPEN_CODEX_APIs=https://codex-api.hgtx.com.br/api/
VITE_BASE_URL_HGTX_CORE_API=https://core-api.hgtx.com.br/api/
VITE_BASE_URL_HGTX_CORE=https://core.hgtx.com.br/
# API Key para autenticação
# IMPORTANTE: Mantenha esta chave segura e não compartilhe publicamente
VITE_API_KEY=your-api-key-here
# User Configuration
# Configure com os dados do usuário/estabelecimento
VITE_USER_EMAIL=user@example.com
VITE_ESTABELECIMENTO_ID=1
# Instruções:
# 1. Copie este arquivo para .env
# 2. Substitua os valores acima com as configurações reais
# 3. NUNCA commite o arquivo .env no git
+5
View File
@@ -12,6 +12,11 @@ dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env.production
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+4 -4
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="pt-br">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -10,11 +10,11 @@
<meta property="og:title" content="HGTX Codex - AI Interface System" />
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta property="og:image" content="/favicon.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:site" content="@hgtx" />
<meta name="twitter:image" content="/favicon.png" />
</head>
<body>
+268
View File
@@ -37,12 +37,14 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
@@ -3324,6 +3326,12 @@
"node": ">=10"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -3362,6 +3370,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3436,6 +3455,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3583,6 +3615,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -3792,6 +3836,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -3820,6 +3873,20 @@
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -3867,6 +3934,51 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -4253,6 +4365,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@@ -4269,6 +4401,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -4306,6 +4454,30 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -4315,6 +4487,19 @@
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -4384,6 +4569,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -4401,6 +4598,33 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -4605,6 +4829,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5174,6 +5406,15 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5196,6 +5437,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -5631,6 +5893,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+2
View File
@@ -40,12 +40,14 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+26 -3
View File
@@ -2,10 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "next-themes";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect";
import { GlobalFunctions } from "./GlobalFunctions";
import React from "react";
import IntelligenceIAApp from "./modules/intelligence-ia/App";
const queryClient = new QueryClient();
@@ -17,9 +21,18 @@ const App = () => (
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route
path="/redir/:gsikey"
element={<Redirect />}
/>
{/* Módulo Intelligence IA como subpath*/}
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
<Route path="/*" element={<Index />} />
<Route path="*" element={<NotFound />} />
<Route path="/404" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
@@ -28,3 +41,13 @@ const App = () => (
);
export default App;
const HGTXLogin = () =>{
React.useEffect(() => {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
});
return(<></>);
}
+286
View File
@@ -0,0 +1,286 @@
import { jwtDecode } from 'jwt-decode';
import { RedirectService } from './pages/RedirectService';
export class GlobalFunctions {
public static getTranferAreaObj(): TransferAreaResponse {
let objRetorno: TransferAreaResponse = {
aplicativoID: 0,
aplicativoNome: "",
areaTransferenciaID: 0,
dataRegistro: "",
estabelecimentoID: 0,
usuarioID: 0,
usuarioEmail: "",
usuarioNome: "",
aplicativoShareCode: "",
nivelAcesso: 0
};
try {
const objTransferStr = sessionStorage.getItem("TRANSFER_AREA");
if (
objTransferStr != null &&
objTransferStr != undefined &&
objTransferStr.trim() != ""
) {
const objTransfer = <TransferAreaResponse>JSON.parse(objTransferStr);
objRetorno = objTransfer;
}
} catch (error) {}
return objRetorno;
}
public static getRedirectComponent(): string {
let objRetorno = "";
try {
const objTransfer = GlobalFunctions.getTranferAreaObj();
if (objTransfer != null) {
// let objRedir = objTransfer.find(x => x.name == 'REDIRECT');
// objRetorno = objRedir?.value ?? '';
objRetorno = objTransfer.urlDestino ?? "";
if (objRetorno.startsWith("/")) objRetorno = objRetorno.substring(1);
} else {
objRetorno = "";
}
} catch (error) {
//console.log(error);
objRetorno = "";
}
return objRetorno;
}
public static getTransferProperty(propertyName: TransferAreaProperties): any {
let objRetorno: any = "";
try {
const objTransfer = this.getTranferAreaObj();
if (objTransfer != null) {
//let obj: any = objTransfer[propertyName];
const element = objTransfer[propertyName];
objRetorno = element;
} else {
objRetorno = null;
}
} catch (error) {
objRetorno = null;
}
return objRetorno;
}
public static isUsuarioLogado(): boolean {
let jsonUsuario = sessionStorage.getItem('usuarioLogado');
if (jsonUsuario) {
let tokenDecode: any = GlobalFunctions.getUsuarioLogado();
let d: Date = new Date();
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
if (dateAtual > tokenDecode.exp) return false;
return true;
}
return false;
}
public static tokenExpired(tokenDecode: any): boolean {
let d: Date = new Date();
let dateAtual: number = Date.UTC(d.getFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes() + 5, d.getUTCSeconds()) / 1000;
if (dateAtual > tokenDecode.exp) return true;
return false;
}
public static handleNavigateBack(): void {
try {
// Verifica se existe histórico de navegação
if (window.history && window.history.length > 1) {
window.history.back();
return;
}
// Tenta fechar a janela
if (!window.closed) {
try {
const windowRef = window.open('', '_self');
if (windowRef) {
windowRef.close();
// Pequeno timeout para verificar se a janela foi fechada
setTimeout(() => {
// Se ainda estiver aberta, redireciona
if (!windowRef.closed) {
window.location.href = "https://core.hgtx.com.br/aplicativos/";
}
}, 100);
return;
}
} catch (closeError) {
console.warn("Não foi possível fechar a janela:", closeError);
}
}
// Se não conseguiu fechar, redireciona
window.location.href = "https://core.hgtx.com.br/aplicativos/";
} catch (error) {
// Em caso de qualquer erro, garante o redirecionamento
console.error("Erro ao tentar navegar:", error);
window.location.href = "https://core.hgtx.com.br/aplicativos/";
}
}
public static getUsuarioLogado(): UsuarioLogadoData {
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
//return jwt_decode(objJwt.token);
return jwtDecode(objJwt.token);
} catch (error) {}
return {
EID: "0",
email: "",
exp: 0,
iat: 0,
jti: "",
name: "",
nbf: 0,
UID: "0",
};
}
public static getUsuarioLogadoToken(): string {
let retorno = "";
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
retorno = objJwt.token;
} catch (error) {}
return retorno;
}
public static getUsuarioLogadoRefreshToken(): string {
let retorno = "";
const jsonUsuario = sessionStorage.getItem("usuarioLogado") ?? "";
try {
const objJwt = JSON.parse(jsonUsuario);
retorno = objJwt.refreshToken;
} catch (error) {}
return retorno;
}
public static getPerfilUsuario(): PerfilUsuario | null {
let retorno: PerfilUsuario | null = null;
const jsonUsuario = sessionStorage.getItem("perfilUsuario") ?? "";
try {
const objJwt = <PerfilUsuario>JSON.parse(jsonUsuario);
retorno = objJwt;
} catch (error) {
retorno = null;
}
return retorno;
}
public static getHighUserProfile(): number {
let retorno = 6;
const objPerfil = GlobalFunctions.getPerfilUsuario();
if (objPerfil) {
const tiposPerfil: number[] = objPerfil.results.map((v) => v.tipoPerfil);
retorno = Math.min(...tiposPerfil);
}
return retorno;
}
public static async getToken(): Promise<string> {
let token: string = GlobalFunctions.getUsuarioLogadoToken();
const tokenDecode: any = GlobalFunctions.getUsuarioLogado();
let currentDate = new Date();
// JWT exp is in seconds
if ((tokenDecode.exp * 1000) < currentDate.getTime()) {
//console.log("Token expired.");
let gsikey: number = parseInt(sessionStorage.getItem('gsikey') ?? '0');
RedirectService.loadTransferArea(gsikey).then((response) => {
if(response.data.errorCode == 0){
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
sessionStorage.setItem('usuarioLogado',JSON.stringify({
token: response.data.jwt.token,
refreshToken: response.data.jwt.refreshToken
}));
token = response.data.jwt.token;
}
// else if(error !== 1401){
// navigate('/',{replace: true});
// }
});
}
// else {
// //console.log("Valid token");
// result = true;
// }
return token;
}
}
export interface UsuarioLogadoData {
UID: string;
EID: string;
name: string;
email: string;
jti: string;
nbf: number;
exp: number;
iat: number;
}
export interface TransferAreaResponse {
estabelecimentoID: number;
nivelAcesso: number;
aplicativoID: number;
usuarioID: number;
areaTransferenciaID: number;
dataRegistro: string;
urlBase?: string;
urlDestino?: string;
aplicativoNome: string;
usuarioNome: string;
usuarioEmail: string;
aplicativoShareCode: string;
}
export enum TransferAreaProperties {
EstabelecimentoCodigo = "estabelecimentoID",
UsuarioCodigo = "usuarioID",
UsuarioNome = "usuarioNome",
UsuarioEmail = "usuarioEmail",
APP_COD = "aplicativoID",
APP_TITULO = "aplicativoNome",
areaTransferenciaID = "areaTransferenciaID",
urlBase = "urlBase",
urlDestino = "urlDestino",
aplicativoShareCode = "aplicativoShareCode",
nivelAcesso = "nivelAcesso",
}
type PerfilUsuario = {
errorCode: number;
errorMessage: string;
results: PerfilUsuarioResult[];
};
export type PerfilUsuarioResult = {
tipoPerfil: number;
itens: PerfilUsuarioItem[];
};
type PerfilUsuarioItem = {
id: number;
descricao: string;
identificador: string;
};
+56 -22
View File
@@ -1,4 +1,4 @@
import { ChevronDown, UserCog } from "lucide-react";
import { ChevronDown, UserCog, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -15,7 +15,11 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
import { useState, useEffect } from "react";
import { fetchModelsFromDatabase, ModelConfig } from "@/config/models";
// Texto padrão da personalidade (mesmo valor usado em ChatView)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface ChatHeaderProps {
showModelSelector?: boolean;
@@ -27,20 +31,32 @@ interface ChatHeaderProps {
export const ChatHeader = ({
showModelSelector = false,
selectedModel = "ChatGPT 4.1",
selectedModel = "GPT-4o",
onModelChange,
systemPrompt = "Você é um assistente útil e prestativo.",
systemPrompt = DEFAULT_SYSTEM_PROMPT,
onSystemPromptChange
}: ChatHeaderProps) => {
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
const [models, setModels] = useState<ModelConfig[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(true);
const models = [
"ChatGPT 4.1",
"ChatGPT 5",
"Claude Sonnet 4.5",
"Claude Opus 4.1",
];
// Carrega modelos da API ao montar o componente
useEffect(() => {
const loadModels = async () => {
setIsLoadingModels(true);
try {
const modelsFromAPI = await fetchModelsFromDatabase();
setModels(modelsFromAPI);
} catch (error) {
console.error('Erro ao carregar modelos:', error);
} finally {
setIsLoadingModels(false);
}
};
loadModels();
}, []);
const handleSavePersonality = () => {
if (onSystemPromptChange) {
@@ -50,8 +66,8 @@ export const ChatHeader = ({
};
return (
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
<span className="text-sm text-muted-foreground">Online</span>
@@ -60,19 +76,37 @@ export const ChatHeader = ({
{showModelSelector && onModelChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 glass-effect">
<span className="font-medium">{selectedModel}</span>
<ChevronDown className="w-4 h-4" />
<Button
variant="outline"
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
disabled={isLoadingModels}
>
{isLoadingModels ? (
<>
<Loader2 className="w-3 h-3 md:w-4 md:h-4 animate-spin" />
<span className="font-medium">Carregando...</span>
</>
) : (
<>
<span className="font-medium truncate max-w-[80px] md:max-w-none">{selectedModel}</span>
<ChevronDown className="w-3 h-3 md:w-4 md:h-4" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
{models.map((model) => (
<DropdownMenuItem
key={model}
key={model.id}
className="cursor-pointer"
onClick={() => onModelChange(model)}
onClick={() => onModelChange(model.name)}
>
{model}
<div className="flex flex-col">
<span className="font-medium">{model.name}</span>
{model.description && (
<span className="text-xs text-muted-foreground">{model.description}</span>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -84,11 +118,11 @@ export const ChatHeader = ({
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2 glass-effect"
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
onClick={() => setTempSystemPrompt(systemPrompt)}
>
<UserCog className="w-4 h-4" />
<span className="font-medium">Personalidade</span>
<UserCog className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden md:inline font-medium">Personalidade</span>
</Button>
</DialogTrigger>
<DialogContent>
@@ -120,7 +154,7 @@ export const ChatHeader = ({
)}
</div>
<div className="text-sm text-muted-foreground">
<div className="hidden md:block text-sm text-muted-foreground">
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
</div>
</header>
+35
View File
@@ -0,0 +1,35 @@
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
interface FullScreenLoaderProps {
show: boolean;
text?: string;
}
export default function FullScreenLoader({ show, text = "Carregando..." }: FullScreenLoaderProps) {
const [visible, setVisible] = useState(show);
useEffect(() => {
if (show) setVisible(true);
else {
// adiciona um pequeno atraso para suavizar o fade-out
const timeout = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(timeout);
}
}, [show]);
if (!visible && !show) return null;
return (
<div
className={`fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200 ${
show ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-card shadow-lg border border-border transition-all duration-200">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-card-foreground text-sm font-medium">{text}</p>
</div>
</div>
);
}
+66 -49
View File
@@ -1,16 +1,17 @@
import { useState } from "react";
import { MessageSquare, Image, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Sparkles } from "lucide-react";
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalFunctions } from "@/GlobalFunctions";
interface LayoutProps {
children: React.ReactNode;
activeTab: "chat" | "images" | "audio" | "bots" | "agent";
onTabChange: (tab: "chat" | "images" | "audio" | "bots" | "agent") => void;
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent") => void;
}
type TabType = "chat" | "images" | "audio" | "bots" | "agent";
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -18,16 +19,17 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
const tabs = [
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
{ id: "images" as TabType, label: "Imagens", icon: Image },
{ id: "audio" as TabType, label: "Áudio", icon: Mic },
{ id: "bots" as TabType, label: "Bots", icon: Bot },
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Sparkles },
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
];
return (
<div className="flex h-screen w-full bg-background overflow-hidden">
{/* Sidebar */}
{!isSidebarCollapsed ? (
<aside className="w-72 bg-sidebar border-r border-sidebar-border flex flex-col">
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
{/* Logo */}
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
<div>
@@ -46,42 +48,42 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
{/* Navigation Tabs */}
<nav className="flex-1 px-3 py-2 space-y-1">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground cyber-border"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{tab.label}</span>
</button>
);
})}
</nav>
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground cyber-border"
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
}`}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{tab.label}</span>
</button>
);
})}
</nav>
<Separator className="bg-sidebar-border" />
<Separator className="bg-sidebar-border" />
{/* Settings & Theme */}
<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 />
{/* Settings & Theme */}
<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">
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</div>
<Button variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
<ArrowLeft className="w-4 h-4" />
Voltar
</Button>
</div>
</aside>
</aside>
) : (
<aside className="w-16 bg-sidebar border-r border-sidebar-border flex flex-col">
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
{/* Collapsed Header with Expand Button */}
<div className="p-3 border-b border-sidebar-border space-y-2">
<Button
@@ -127,12 +129,7 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
{/* Theme & Settings Icons */}
<div className="p-3 border-t border-sidebar-border space-y-2">
<ThemeToggle />
<Button
variant="ghost"
size="icon"
className="w-full text-sidebar-foreground"
title="Voltar"
>
<Button variant="ghost" size="icon" className="w-full text-sidebar-foreground" title="Voltar">
<ArrowLeft className="w-4 h-4" />
</Button>
</div>
@@ -140,9 +137,29 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
)}
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden">
{children}
</main>
<main className="flex-1 flex flex-col overflow-hidden w-full">{children}</main>
{/* Mobile Navigation */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all ${
isActive
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/70"
}`}
>
<Icon className={`w-5 h-5 ${isActive ? "text-primary" : ""}`} />
<span className="text-xs font-medium">{tab.label}</span>
</button>
);
})}
</nav>
</div>
);
};
-195
View File
@@ -1,195 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Download, Send, Sparkles } from "lucide-react";
import { LegalOpinion } from "./AgentView";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
interface AgentChatProps {
selectedOpinion: LegalOpinion | null;
isCreatingNew: boolean;
onOpinionCreated: (opinion: LegalOpinion) => void;
}
export const AgentChat = ({
selectedOpinion,
isCreatingNew,
onOpinionCreated,
}: AgentChatProps) => {
const [title, setTitle] = useState("");
const [instructions, setInstructions] = useState("");
const [category, setCategory] = useState("");
const [generatedContent, setGeneratedContent] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const { toast } = useToast();
const handleGenerate = async () => {
if (!instructions.trim()) {
toast({
title: "Instruções necessárias",
description: "Por favor, forneça instruções para gerar o parecer.",
variant: "destructive",
});
return;
}
setIsGenerating(true);
// Simulação de chamada à IA - aqui você integraria com a API real
setTimeout(() => {
const mockContent = `PARECER JURÍDICO
TÍTULO: ${title || "Parecer Jurídico"}
${instructions}
ANÁLISE:
Este parecer foi gerado com base nas instruções fornecidas. A análise considera os seguintes aspectos legais:
1. Fundamentação Legal
2. Precedentes Judiciais
3. Doutrina Aplicável
4. Conclusão e Recomendações
CONCLUSÃO:
Com base na análise realizada, conclui-se que...
___________________________
Parecer gerado em ${new Date().toLocaleDateString('pt-BR')}`;
setGeneratedContent(mockContent);
setIsGenerating(false);
if (isCreatingNew) {
const newOpinion: LegalOpinion = {
id: Date.now().toString(),
title: title || "Novo Parecer",
content: mockContent,
createdAt: new Date(),
category: category || undefined,
};
onOpinionCreated(newOpinion);
}
toast({
title: "Parecer gerado com sucesso!",
description: "O parecer está pronto para download.",
});
}, 2000);
};
const handleDownloadDocx = () => {
// Aqui você implementaria a geração real do DOCX
// Por enquanto, vamos criar um arquivo de texto
const content = generatedContent || selectedOpinion?.content || "";
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || selectedOpinion?.title || 'parecer'}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
};
const displayContent = generatedContent || selectedOpinion?.content;
return (
<div className="flex flex-col h-full bg-background">
<div className="p-6 border-b border-border">
<h2 className="text-2xl font-bold text-foreground">
{selectedOpinion ? selectedOpinion.title : "Novo Parecer Jurídico"}
</h2>
{selectedOpinion && (
<p className="text-sm text-muted-foreground mt-1">
Criado em {new Date(selectedOpinion.createdAt).toLocaleDateString('pt-BR')}
</p>
)}
</div>
<div className="flex-1 overflow-hidden">
<div className="h-full p-6 space-y-4">
{(isCreatingNew || !selectedOpinion) && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Título do Parecer</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Categoria</label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Ex: Direito Civil, Trabalhista, etc."
/>
</div>
</>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{selectedOpinion ? "Instruções para novo modelo" : "Instruções para gerar o parecer"}
</label>
<Textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
className="min-h-[120px]"
/>
</div>
<Button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full gap-2"
>
{isGenerating ? (
<>Gerando parecer...</>
) : (
<>
<Sparkles className="w-4 h-4" />
{selectedOpinion ? "Gerar Novo Modelo com IA" : "Gerar Parecer com IA"}
</>
)}
</Button>
{displayContent && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Parecer Gerado</label>
<Button
onClick={handleDownloadDocx}
variant="outline"
size="sm"
className="gap-2"
>
<Download className="w-4 h-4" />
Baixar DOCX
</Button>
</div>
<ScrollArea className="h-[300px] rounded-md border border-border p-4">
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
{displayContent}
</pre>
</ScrollArea>
</div>
)}
</div>
</div>
</div>
);
};
+164 -87
View File
@@ -1,72 +1,81 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Search, FileText } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LegalOpinion } from "./AgentView";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
interface AgentSearchProps {
onSelectOpinion: (opinion: LegalOpinion) => void;
onSelectOpinion: (opinion: OpinionRecord) => void;
}
// Simulação de base de pareceres
const mockOpinionsDatabase: LegalOpinion[] = [
{
id: "base-1",
title: "Análise Contratual - Prestação de Serviços Continuados",
content: "Parecer completo sobre prestação de serviços...",
createdAt: new Date("2024-01-15"),
category: "Direito Civil",
},
{
id: "base-2",
title: "Rescisão de Contrato de Trabalho - Justa Causa",
content: "Análise jurídica sobre rescisão contratual...",
createdAt: new Date("2024-02-20"),
category: "Direito Trabalhista",
},
{
id: "base-3",
title: "Responsabilidade Civil - Acidente de Trânsito",
content: "Parecer sobre responsabilidade civil em acidentes...",
createdAt: new Date("2024-03-10"),
category: "Direito Civil",
},
{
id: "base-4",
title: "Dissolução de Sociedade - Procedimentos e Requisitos",
content: "Análise completa sobre dissolução societária...",
createdAt: new Date("2024-01-25"),
category: "Direito Empresarial",
},
{
id: "base-5",
title: "Direitos do Consumidor - Vícios em Produtos",
content: "Parecer sobre direitos do consumidor e garantias...",
createdAt: new Date("2024-02-05"),
category: "Direito do Consumidor",
},
];
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<LegalOpinion[]>([]);
const [searchResults, setSearchResults] = useState<OpinionRecord[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleSearch = () => {
// Busca pareceres da API
const handleSearch = async () => {
setIsLoading(true);
try {
const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
setSearchResults(data);
} catch (error: any) {
console.error('Erro ao buscar pareceres:', error);
toast({
title: "Erro ao buscar pareceres",
description: error.message || "Não foi possível buscar os pareceres.",
variant: "destructive",
});
setSearchResults([]);
} finally {
setIsLoading(false);
}
};
// Busca automaticamente quando a página ou itemsPerPage mudam
useEffect(() => {
if (searchTerm.trim()) {
handleSearch();
}
}, [currentPage, itemsPerPage]);
// Debounce para busca automática
useEffect(() => {
if (!searchTerm.trim()) {
setSearchResults([]);
return;
}
const results = mockOpinionsDatabase.filter(
(opinion) =>
opinion.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
opinion.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
opinion.category?.toLowerCase().includes(searchTerm.toLowerCase())
);
const timer = setTimeout(() => {
if (currentPage === 1) {
handleSearch();
} else {
setCurrentPage(1); // Volta para primeira página ao buscar
}
}, 500);
setSearchResults(results);
};
return () => clearTimeout(timer);
}, [searchTerm]);
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
return (
<div className="flex flex-col h-full bg-background">
@@ -76,54 +85,122 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Digite título, frase ou categoria..."
placeholder="Digite título ou categoria..."
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button onClick={handleSearch} className="gap-2">
<Button onClick={handleSearch} className="gap-2" disabled={isLoading}>
<Search className="w-4 h-4" />
Buscar
{isLoading ? 'Buscando...' : 'Buscar'}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Base com {mockOpinionsDatabase.length} pareceres disponíveis
</p>
{searchResults.length > 0 && (
<p className="text-sm text-muted-foreground">
{searchResults.length} {searchResults.length === 1 ? 'parecer encontrado' : 'pareceres encontrados'}
</p>
)}
</div>
<ScrollArea className="flex-1 p-6">
{searchResults.length > 0 ? (
<div className="space-y-3">
{searchResults.map((opinion) => (
<div
key={opinion.id}
className="p-4 border border-border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectOpinion(opinion)}
>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-primary mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-foreground">{opinion.title}</h3>
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
<span>{opinion.category}</span>
<span>{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}</span>
<div className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-6">
{isLoading ? (
<div className="text-center text-muted-foreground py-12">
<p>Buscando pareceres...</p>
</div>
) : searchResults.length > 0 ? (
<div className="space-y-3">
{searchResults.map((opinion) => (
<div
key={opinion.id}
className="p-4 border border-border rounded-lg hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => onSelectOpinion(opinion)}
>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-primary mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-foreground">{opinion.titulo}</h3>
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
<span>{opinion.categoria || '-'}</span>
<span>{new Date(opinion.created_at).toLocaleDateString('pt-BR')}</span>
</div>
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{opinion.instrucoes}
</p>
</div>
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{opinion.content}
</p>
</div>
</div>
))}
</div>
) : searchTerm ? (
<div className="text-center text-muted-foreground py-12">
<p>Nenhum parecer encontrado para "{searchTerm}"</p>
</div>
) : (
<div className="text-center text-muted-foreground py-12">
<p>Digite um termo para pesquisar na base de pareceres</p>
</div>
)}
</ScrollArea>
{searchResults.length > 0 && (
<div className="p-4 border-t border-border space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Itens por página:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
) : searchTerm ? (
<div className="text-center text-muted-foreground py-12">
<p>Nenhum parecer encontrado para "{searchTerm}"</p>
</div>
) : (
<div className="text-center text-muted-foreground py-12">
<p>Digite um termo para pesquisar na base de pareceres</p>
<span className="text-sm text-muted-foreground">
Mostrando {startIndex + 1}-{Math.min(startIndex + itemsPerPage, searchResults.length)} de {searchResults.length}
</span>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</ScrollArea>
</div>
</div>
);
};
-121
View File
@@ -1,121 +0,0 @@
import { Plus, Search, Trash2, Eye } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LegalOpinion } from "./AgentView";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface AgentSidebarProps {
opinions: LegalOpinion[];
onCreateNew: () => void;
onSelectOpinion: (opinion: LegalOpinion) => void;
onDeleteOpinion: (id: string) => void;
onSearch: () => void;
selectedOpinionId?: string;
}
export const AgentSidebar = ({
opinions,
onCreateNew,
onSelectOpinion,
onDeleteOpinion,
onSearch,
selectedOpinionId,
}: AgentSidebarProps) => {
const [searchTerm, setSearchTerm] = useState("");
const filteredOpinions = opinions.filter(op =>
op.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="w-80 border-r border-border bg-sidebar flex flex-col h-full">
<div className="p-4 border-b border-border space-y-3">
<h2 className="text-xl font-bold text-sidebar-foreground">Agente de Parecer</h2>
<Button onClick={onCreateNew} className="w-full gap-2">
<Plus className="w-4 h-4" />
Novo Parecer
</Button>
<Button onClick={onSearch} variant="outline" className="w-full gap-2">
<Search className="w-4 h-4" />
Pesquisar Base de Pareceres
</Button>
<Input
placeholder="Buscar pareceres..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Título</TableHead>
<TableHead className="w-[120px]">Data</TableHead>
<TableHead className="w-[100px] text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredOpinions.map((opinion) => (
<TableRow
key={opinion.id}
className={selectedOpinionId === opinion.id ? "bg-sidebar-accent" : ""}
>
<TableCell className="font-medium">
<div>
<p className="truncate">{opinion.title}</p>
{opinion.category && (
<p className="text-xs text-muted-foreground mt-1">{opinion.category}</p>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onSelectOpinion(opinion)}
title="Visualizar"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDeleteOpinion(opinion.id)}
title="Excluir"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filteredOpinions.length === 0 && (
<div className="text-center text-muted-foreground py-8">
<p className="text-sm">Nenhum parecer encontrado</p>
</div>
)}
</div>
</ScrollArea>
</div>
);
};
+510 -54
View File
@@ -1,74 +1,530 @@
import { useState } from "react";
import { AgentSidebar } from "./AgentSidebar";
import { AgentChat } from "./AgentChat";
import { useState, useEffect, useCallback } from "react";
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { OpinionDialog } from "./OpinionDialog";
import { AgentSearch } from "./AgentSearch";
import { agentService, OpinionRecord } from "@/services/agent";
import { useToast } from "@/hooks/use-toast";
export interface LegalOpinion {
id: string;
title: string;
content: string;
createdAt: Date;
category?: string;
}
type SortField = "titulo" | "created_at" | "categoria";
type SortOrder = "asc" | "desc";
export const AgentView = () => {
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [sortField, setSortField] = useState<SortField>("created_at");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedOpinion, setSelectedOpinion] = useState<OpinionRecord | null>(null);
const [showSearch, setShowSearch] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [opinionToDelete, setOpinionToDelete] = useState<OpinionRecord | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const handleCreateNew = () => {
setIsCreatingNew(true);
setSelectedOpinion(null);
setShowSearch(false);
};
// Carrega pareceres da API
const loadOpinions = useCallback(async () => {
setIsLoading(true);
try {
const data = await agentService.getOpinions({
page: currentPage,
per_page: itemsPerPage,
search: searchTerm,
});
const handleSelectOpinion = (opinion: LegalOpinion) => {
setSelectedOpinion(opinion);
setIsCreatingNew(false);
setShowSearch(false);
};
// Adiciona status aos pareceres da API
const opinionsWithStatus = data.map(opinion => ({
...opinion,
status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
}));
const handleSearch = () => {
setShowSearch(true);
setIsCreatingNew(false);
setSelectedOpinion(null);
};
setOpinions(opinionsWithStatus);
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
setOpinions([newOpinion, ...opinions]);
setSelectedOpinion(newOpinion);
setIsCreatingNew(false);
};
// Remove registros temporários que agora estão na API
setPendingOpinions(prev =>
prev.filter(pending =>
!opinionsWithStatus.some(opinion =>
opinion.titulo === pending.titulo &&
opinion.created_at.substring(0, 10) === pending.created_at.substring(0, 10)
)
)
);
} catch (error: any) {
console.error('Erro ao carregar pareceres:', error);
toast({
title: "Erro ao carregar pareceres",
description: error.message || "Não foi possível carregar a lista de pareceres.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
}, [currentPage, itemsPerPage, searchTerm, toast]);
const handleDeleteOpinion = (id: string) => {
setOpinions(opinions.filter(op => op.id !== id));
if (selectedOpinion?.id === id) {
setSelectedOpinion(null);
// Polling automático para atualizar status dos pareceres
useEffect(() => {
// Carrega imediatamente
loadOpinions();
// Configura polling a cada 10 segundos se houver pareceres pendentes
const interval = setInterval(() => {
if (pendingOpinions.length > 0) {
console.log('Polling: Atualizando lista de pareceres...');
loadOpinions();
}
}, 10000); // 10 segundos
return () => clearInterval(interval);
}, [currentPage, itemsPerPage, pendingOpinions.length, loadOpinions]);
// Debounce para busca
useEffect(() => {
const timer = setTimeout(() => {
if (currentPage === 1) {
loadOpinions();
} else {
setCurrentPage(1); // Volta para a primeira página ao buscar
}
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, loadOpinions]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("asc");
}
};
// Mescla pareceres da API com registros temporários
const allOpinions = [...pendingOpinions, ...opinions];
const sortedOpinions = [...allOpinions].sort((a, b) => {
const multiplier = sortOrder === "asc" ? 1 : -1;
if (sortField === "created_at") {
return multiplier * (new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
}
const aValue = a[sortField] || "";
const bValue = b[sortField] || "";
return multiplier * aValue.toString().localeCompare(bValue.toString());
});
const totalPages = Math.ceil(sortedOpinions.length / itemsPerPage);
// Callback quando um parecer está sendo criado (registro temporário)
const handleOpinionCreating = (tempOpinion: OpinionRecord) => {
setPendingOpinions(prev => [tempOpinion, ...prev]);
};
// Callback quando um parecer foi criado (recarrega da API)
const handleOpinionCreated = () => {
loadOpinions();
};
const handleViewOpinion = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setIsDialogOpen(true);
};
const handleNewOpinion = () => {
setSelectedOpinion(null);
setIsDialogOpen(true);
};
const handleSelectFromSearch = (opinion: OpinionRecord) => {
setSelectedOpinion(opinion);
setShowSearch(false);
setIsDialogOpen(true);
};
const handleDownloadVersion = async (opinion: OpinionRecord, version: 'v1' | 'v2') => {
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
const handleDeleteOpinion = async () => {
if (!opinionToDelete) return;
setIsDeleting(true);
try {
await agentService.deleteOpinion(opinionToDelete.id);
toast({
title: "Parecer excluído",
description: "O parecer foi excluído com sucesso.",
});
// Recarrega a lista de pareceres
await loadOpinions();
// Fecha o diálogo de confirmação
setOpinionToDelete(null);
} catch (error: any) {
toast({
title: "Erro ao excluir parecer",
description: error.message || "Não foi possível excluir o parecer.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
if (showSearch) {
return (
<div className="flex flex-col h-full">
<div className="p-6 border-b border-border">
<Button variant="outline" onClick={() => setShowSearch(false)}>
Voltar para Pareceres
</Button>
</div>
<AgentSearch onSelectOpinion={handleSelectFromSearch} />
</div>
);
}
return (
<div className="flex h-full">
<AgentSidebar
opinions={opinions}
onCreateNew={handleCreateNew}
onSelectOpinion={handleSelectOpinion}
onDeleteOpinion={handleDeleteOpinion}
onSearch={handleSearch}
selectedOpinionId={selectedOpinion?.id}
/>
<div className="flex-1">
{showSearch ? (
<AgentSearch onSelectOpinion={handleSelectOpinion} />
) : (
<AgentChat
selectedOpinion={selectedOpinion}
isCreatingNew={isCreatingNew}
onOpinionCreated={handleOpinionCreated}
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
<div className="p-3 md:p-6 border-b border-border">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
<h1 className="text-xl md:text-3xl font-bold text-foreground">Gerador de Modelos de Parecer Jurídico</h1>
<div className="flex gap-2 w-full md:w-auto">
<Button onClick={() => setShowSearch(true)} variant="outline" className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Search className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Pesquisar Base</span>
<span className="sm:hidden">Base</span>
</Button>
<Button onClick={handleNewOpinion} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
<Plus className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Novo Parecer</span>
<span className="sm:hidden">Novo</span>
</Button>
</div>
</div>
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
<Input
placeholder="Buscar pareceres..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full md:max-w-sm text-sm"
/>
<div className="flex items-center gap-2 justify-between md:justify-start">
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => {
setItemsPerPage(Number(value));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-16 md:w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
<div className="border rounded-lg overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[200px]">
<Button
variant="ghost"
onClick={() => handleSort("titulo")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Título
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</TableHead>
<TableHead className="hidden md:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("categoria")}
className="flex items-center gap-1 font-semibold text-sm"
>
Categoria
<ArrowUpDown className="w-4 h-4" />
</Button>
</TableHead>
<TableHead className="hidden lg:table-cell text-center">
<span className="font-semibold text-xs md:text-sm">Status</span>
</TableHead>
<TableHead className="hidden sm:table-cell">
<Button
variant="ghost"
onClick={() => handleSort("created_at")}
className="flex items-center gap-1 font-semibold text-xs md:text-sm p-1 md:p-2"
>
Data
<ArrowUpDown className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
Carregando pareceres...
</TableCell>
</TableRow>
) : sortedOpinions.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground">
{searchTerm
? "Nenhum parecer encontrado"
: "Nenhum parecer criado ainda. Clique em 'Novo Parecer' para começar."}
</TableCell>
</TableRow>
) : (
sortedOpinions.map((opinion) => (
<TableRow key={opinion.id}>
<TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{opinion.categoria || "-"}</TableCell>
<TableCell className="hidden lg:table-cell text-center">
{opinion.status === 'processando' && (
<Badge variant="secondary" className="gap-1 bg-yellow-100 text-yellow-800 hover:bg-yellow-100">
<Loader2 className="w-3 h-3 animate-spin" />
Gerando...
</Badge>
)}
{opinion.status === 'concluido' && (
<Badge variant="secondary" className="gap-1 bg-green-100 text-green-800 hover:bg-green-100">
<CheckCircle2 className="w-3 h-3" />
Concluído
</Badge>
)}
{opinion.status === 'erro' && (
<Badge variant="destructive" className="gap-1">
<XCircle className="w-3 h-3" />
Erro
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell text-xs md:text-sm">
{new Date(opinion.created_at).toLocaleDateString("pt-BR")}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewOpinion(opinion)}
title="Visualizar"
className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
>
<Eye className="w-3 h-3 md:w-4 md:h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title="Baixar"
className="h-7 w-7 md:h-9 md:w-9"
disabled={opinion.isLocalPending || opinion.status === 'processando'}
>
<Download className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v1')}
disabled={!opinion.file_url}
>
<Download className="w-4 h-4 mr-2" />
Versão 1
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDownloadVersion(opinion, 'v2')}
disabled={!opinion.file_url_melhoria}
>
<Download className="w-4 h-4 mr-2" />
Versão Melhorada
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
onClick={() => setOpinionToDelete(opinion)}
title="Excluir"
className="h-7 w-7 md:h-9 md:w-9 text-destructive hover:text-destructive"
disabled={opinion.isLocalPending}
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{sortedOpinions.length > 0 && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
Mostrando {sortedOpinions.length} {sortedOpinions.length === 1 ? 'parecer' : 'pareceres'}
</p>
<div className="flex gap-1 md:gap-2 justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Anterior</span>
<span className="sm:hidden">Ant</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let page;
if (totalPages <= 5) {
page = i + 1;
} else if (currentPage <= 3) {
page = i + 1;
} else if (currentPage >= totalPages - 2) {
page = totalPages - 4 + i;
} else {
page = currentPage - 2 + i;
}
return (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="text-xs md:text-sm px-2 md:px-4"
>
<span className="hidden sm:inline">Próxima</span>
<span className="sm:hidden">Prox</span>
</Button>
</div>
</div>
)}
</div>
<OpinionDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
selectedOpinion={selectedOpinion}
onOpinionCreated={handleOpinionCreated}
onOpinionCreating={handleOpinionCreating}
/>
<AlertDialog open={!!opinionToDelete} onOpenChange={(open) => !open && setOpinionToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir o parecer "{opinionToDelete?.titulo}"? Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOpinion}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Excluindo..." : "Excluir"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
+297
View File
@@ -0,0 +1,297 @@
import { useState, useEffect } from "react";
import { Download, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@/components/ui/toast";
import { agentService, OpinionRecord } from "@/services/agent";
interface OpinionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedOpinion: OpinionRecord | null;
onOpinionCreated: () => void;
onOpinionCreating?: (tempOpinion: OpinionRecord) => void; // Callback para adicionar registro temporário
}
export const OpinionDialog = ({
open,
onOpenChange,
selectedOpinion,
onOpinionCreated,
onOpinionCreating,
}: OpinionDialogProps) => {
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [instructions, setInstructions] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [createdOpinion, setCreatedOpinion] = useState<OpinionRecord | null>(null);
const { toast } = useToast();
useEffect(() => {
if (selectedOpinion) {
setTitle(selectedOpinion.titulo);
setCategory(selectedOpinion.categoria || "");
setInstructions(selectedOpinion.instrucoes || "");
setCreatedOpinion(selectedOpinion);
} else {
setTitle("");
setCategory("");
setInstructions("");
setCreatedOpinion(null);
}
}, [selectedOpinion, open]);
const handleGenerate = async () => {
if (!instructions.trim()) {
toast({
title: "Instruções necessárias",
description: "Por favor, forneça instruções para gerar o parecer.",
variant: "destructive",
});
return;
}
if (!title.trim()) {
toast({
title: "Título necessário",
description: "Por favor, forneça um título para o parecer.",
variant: "destructive",
});
return;
}
// Criar registro temporário para adicionar na tabela imediatamente
const tempId = `temp-${Date.now()}`;
const tempOpinion: OpinionRecord = {
id: tempId,
estabelecimento_id: 0,
user_email: '',
titulo: title,
categoria: category,
instrucoes: instructions,
file_url: '',
file_url_melhoria: '',
created_at: new Date().toISOString(),
status: 'processando',
isLocalPending: true,
};
// Adiciona o registro temporário na tabela
if (onOpinionCreating) {
onOpinionCreating(tempOpinion);
}
// Criar toast de progresso que não fecha automaticamente
const loadingToast = toast({
title: "Gerando parecer jurídico...",
description: "O parecer está sendo processado. Isso pode levar alguns minutos.",
duration: Infinity, // Toast não fecha automaticamente
});
// Fecha o dialog imediatamente
setIsGenerating(false);
onOpenChange(false);
// Limpa os campos
setTitle("");
setCategory("");
setInstructions("");
setCreatedOpinion(null);
// Executar requisição em background
try {
const response = await agentService.createOpinion({
titulo: tempOpinion.titulo,
categoria: tempOpinion.categoria,
instrucoes: tempOpinion.instrucoes,
});
if (response.success) {
// Recarrega a lista para pegar o parecer real da API
onOpinionCreated();
// Atualizar toast para sucesso
loadingToast.update({
id: loadingToast.id,
title: "Parecer gerado com sucesso!",
description: "O parecer foi criado e está disponível para download.",
duration: 8000, // Toast fecha após 8 segundos
action: (
<ToastAction altText="Fechar notificação" onClick={() => loadingToast.dismiss()}>
OK
</ToastAction>
),
});
} else {
throw new Error('Falha ao criar parecer');
}
} catch (error: any) {
console.error("Erro ao gerar parecer:", error);
const errorMessage = error.message || "Não foi possível gerar o parecer. Tente novamente.";
// Recarrega a lista para remover o registro temporário e tentar pegar dados reais
onOpinionCreated();
// Atualizar toast para erro
loadingToast.update({
id: loadingToast.id,
title: "Erro ao gerar parecer",
description: errorMessage,
variant: "destructive",
duration: 10000, // Toast de erro fecha após 10 segundos
});
}
};
const handleDownloadVersion = async (version: 'v1' | 'v2') => {
if (!createdOpinion && !selectedOpinion) {
toast({
title: "Nenhum parecer disponível",
description: "Por favor, gere um parecer primeiro.",
variant: "destructive",
});
return;
}
const opinion = createdOpinion || selectedOpinion;
if (!opinion) return;
const fileUrl = version === 'v1' ? opinion.file_url : opinion.file_url_melhoria;
if (!fileUrl) {
toast({
title: "Arquivo não disponível",
description: `A ${version === 'v1' ? 'versão 1' : 'versão melhorada'} ainda não está disponível.`,
variant: "destructive",
});
return;
}
try {
const fileName = `${opinion.titulo}_${version === 'v1' ? 'v1' : 'melhorada'}.docx`;
await agentService.downloadOpinion(fileUrl, fileName);
toast({
title: "Download iniciado",
description: "O parecer está sendo baixado.",
});
} catch (error: any) {
toast({
title: "Erro ao fazer download",
description: error.message || "Não foi possível baixar o arquivo.",
variant: "destructive",
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>
{selectedOpinion ? "Visualizar Parecer" : "Novo Parecer Jurídico"}
</DialogTitle>
<DialogDescription>
{selectedOpinion
? "Visualize os detalhes do parecer e faça o download das versões disponíveis"
: "Preencha os dados e instruções para gerar um novo parecer com IA"}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(90vh-120px)] pr-4">
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="title">Título do Parecer *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Análise sobre Contrato de Prestação de Serviços"
disabled={!!selectedOpinion}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Categoria</Label>
<Input
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Ex: Direito Civil, Trabalhista, etc."
disabled={!!selectedOpinion}
/>
</div>
<div className="space-y-2">
<Label htmlFor="instructions">
Instruções para gerar o parecer *
</Label>
<Textarea
id="instructions"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Descreva os detalhes, contexto e aspectos legais que devem ser considerados no parecer..."
className="min-h-[150px]"
/>
</div>
{!selectedOpinion && (
<Button
onClick={handleGenerate}
disabled={isGenerating}
className="w-full gap-2"
>
{isGenerating ? (
<>Gerando parecer...</>
) : (
<>
<Sparkles className="w-4 h-4" />
Gerar Parecer com IA
</>
)}
</Button>
)}
{selectedOpinion && (
<div className="space-y-3 pt-4 border-t">
<Label>Downloads Disponíveis</Label>
<div className="flex flex-col gap-2">
<Button
onClick={() => handleDownloadVersion('v1')}
variant="outline"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url}
>
<Download className="w-4 h-4" />
Baixar Versão 1
</Button>
<Button
onClick={() => handleDownloadVersion('v2')}
variant="outline"
className="w-full gap-2 justify-start"
disabled={!selectedOpinion.file_url_melhoria}
>
<Download className="w-4 h-4" />
Baixar Versão Melhorada
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
+10 -10
View File
@@ -270,22 +270,22 @@ export const AudioView = () => {
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-6">
<Tabs defaultValue="transcription" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 glass-effect">
<TabsTrigger value="transcription">
<div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 glass-effect text-xs md:text-sm">
<TabsTrigger value="transcription" className="text-xs md:text-sm">
Transcrição
</TabsTrigger>
<TabsTrigger value="generation">Geração de Voz</TabsTrigger>
<TabsTrigger value="transcription-history">
Histórico Transcrições
<TabsTrigger value="generation" className="text-xs md:text-sm">Geração</TabsTrigger>
<TabsTrigger value="transcription-history" className="text-xs md:text-sm">
Hist. Trans.
</TabsTrigger>
<TabsTrigger value="audio-history">
Histórico Áudios
<TabsTrigger value="audio-history" className="text-xs md:text-sm">
Hist. Áud.
</TabsTrigger>
</TabsList>
+468
View File
@@ -0,0 +1,468 @@
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Mic, Download, Trash2, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { audioGenerationService, VOICE_OPTIONS, VoiceType, AudioRecord } from "@/services/audioGeneration";
export const GenerationView = () => {
const [textToSpeech, setTextToSpeech] = useState("");
const [isProcessing, setIsProcessing] = useState(false);
const [selectedVoice, setSelectedVoice] = useState<VoiceType>("alloy");
const [lastGeneratedAudio, setLastGeneratedAudio] = useState<AudioRecord | null>(null);
const [audioHistory, setAudioHistory] = useState<AudioRecord[]>([]);
const [isLoadingAudios, setIsLoadingAudios] = useState(false);
const [audioSearchQuery, setAudioSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega áudios do banco de dados
const loadAudios = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingAudios(true);
try {
const fetchedAudios = await audioGenerationService.getAudios(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedAudios)) {
setAudioHistory(fetchedAudios);
} else {
console.warn('Resposta da API não é um array:', fetchedAudios);
setAudioHistory([]);
}
} catch (error: any) {
console.error('Erro ao carregar áudios:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de áudios.",
variant: "destructive",
});
setAudioHistory([]);
} finally {
setIsLoadingAudios(false);
}
};
// Carrega áudios ao montar e quando a paginação mudar
useEffect(() => {
loadAudios();
}, [currentPage, perPage]);
const handleGenerateAudio = async () => {
// Valida o texto antes de enviar
const validation = audioGenerationService.validateText(textToSpeech);
if (!validation.valid) {
toast({
title: "Texto inválido",
description: validation.error,
variant: "destructive",
});
return;
}
setIsProcessing(true);
try {
// Chama o serviço de geração de áudio
const response = await audioGenerationService.generateAudio({
message: textToSpeech,
voice: selectedVoice,
});
// Verifica se a geração foi bem-sucedida
if (response.success) {
console.log('URL do áudio gerado:', response.audio_url);
// Cria objeto do áudio recém-gerado para exibição imediata
const newGeneratedAudio: AudioRecord = {
id: response.audio_generation_id,
user_email: '',
estabelecimento_id: 0,
input_text: response.message,
model: 'tts-1',
voice: selectedVoice,
audio_url: response.audio_url,
duration_seconds: null,
file_size: 0,
cost_usd: '0',
created_at: new Date().toISOString(),
};
// Salva o último áudio gerado para exibição
setLastGeneratedAudio(newGeneratedAudio);
toast({
title: "Áudio gerado com sucesso",
description: `Voz: ${VOICE_OPTIONS[selectedVoice].label}`,
});
// Limpa o campo de texto após sucesso
setTextToSpeech("");
// Recarrega a lista de áudios (sem aguardar para não bloquear a UI)
loadAudios(1, perPage);
setCurrentPage(1);
} else {
throw new Error('Erro ao gerar áudio');
}
} catch (error: any) {
console.error('Erro na geração de áudio:', error);
toast({
title: "Erro na geração",
description: error.message || "Não foi possível gerar o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
};
const handleDownloadAudio = (audio: AudioRecord) => {
const a = document.createElement('a');
a.href = audio.audio_url;
a.download = `audio_${audio.voice}_${new Date(audio.created_at).getTime()}.mp3`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
toast({
title: "Download iniciado",
description: "O arquivo de áudio está sendo baixado.",
});
};
const handleDeleteAudio = async (audioId: string) => {
try {
const result = await audioGenerationService.deleteAudio(audioId);
if (result.success) {
toast({
title: "Áudio removido",
description: "O áudio foi removido com sucesso.",
});
// Se o áudio deletado for o último gerado, limpa o preview
if (lastGeneratedAudio && lastGeneratedAudio.id === audioId) {
setLastGeneratedAudio(null);
}
// Recarrega a lista de áudios
await loadAudios();
} else {
throw new Error(result.message || 'Erro ao deletar áudio');
}
} catch (error: any) {
console.error('Erro ao deletar áudio:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover o áudio.",
variant: "destructive",
});
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredAudios = Array.isArray(audioHistory)
? audioHistory.filter(item =>
(VOICE_OPTIONS[item.voice]?.label || item.voice).toLowerCase().includes(audioSearchQuery.toLowerCase()) ||
item.input_text.toLowerCase().includes(audioSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="generation" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
<TabsTrigger value="generation" className="text-xs md:text-sm">
Geração
</TabsTrigger>
<TabsTrigger value="history" className="text-xs md:text-sm">
Histórico
</TabsTrigger>
</TabsList>
<TabsContent value="generation" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<h3 className="text-lg font-semibold">
Converter Texto em Voz
</h3>
<div className="space-y-2">
<label className="text-sm font-medium">
Digite o texto para converter
</label>
<Textarea
value={textToSpeech}
onChange={(e) => setTextToSpeech(e.target.value)}
placeholder="Digite o texto que deseja converter em fala..."
className="min-h-[120px] resize-none"
/>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Tipo de Voz</label>
<Select value={selectedVoice} onValueChange={(value) => setSelectedVoice(value as VoiceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VOICE_OPTIONS).map(([key, voice]) => (
<SelectItem key={key} value={key}>
{voice.label} - {voice.gender}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{VOICE_OPTIONS[selectedVoice].label}</p>
<span className="text-xs text-muted-foreground">Modelo: OpenAI TTS-1</span>
</div>
<p className="text-xs text-muted-foreground">
<strong>Estilo:</strong> {VOICE_OPTIONS[selectedVoice].style}
</p>
<p className="text-xs text-muted-foreground">
{VOICE_OPTIONS[selectedVoice].description}
</p>
</div>
<Button
onClick={handleGenerateAudio}
disabled={!textToSpeech.trim() || isProcessing}
className="gap-2 cyber-glow w-full"
size="lg"
>
<Mic className="w-4 h-4" />
{isProcessing ? "Gerando..." : "Gerar Áudio"}
</Button>
</div>
{isProcessing && (
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">
Gerando áudio...
</p>
</div>
)}
</div>
{lastGeneratedAudio && (
<div className="glass-effect rounded-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">Áudio Gerado</h4>
<p className="text-sm text-muted-foreground">
Voz: {VOICE_OPTIONS[lastGeneratedAudio.voice]?.label || lastGeneratedAudio.voice}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleDownloadAudio(lastGeneratedAudio)}
>
<Download className="w-3 h-3" />
Baixar
</Button>
<Button
size="sm"
variant="destructive"
className="gap-1"
onClick={() => handleDeleteAudio(lastGeneratedAudio.id)}
>
<Trash2 className="w-3 h-3" />
Excluir
</Button>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm mb-3">{lastGeneratedAudio.input_text}</p>
<audio
key={lastGeneratedAudio.id}
controls
className="w-full"
preload="metadata"
>
<source src={lastGeneratedAudio.audio_url} type="audio/mpeg" />
Seu navegador não suporta o elemento de áudio.
</audio>
</div>
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={audioSearchQuery}
onChange={(e) => setAudioSearchQuery(e.target.value)}
placeholder="Buscar por voz ou conteúdo..."
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingAudios ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando áudios...</p>
</div>
) : (
<>
{filteredAudios.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<Mic className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{audioSearchQuery ? 'Nenhum áudio encontrado' : 'Nenhum áudio gerado ainda'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredAudios.map((audio) => (
<div key={audio.id} className="glass-effect rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">
Voz: {VOICE_OPTIONS[audio.voice]?.label || audio.voice}
</h4>
</div>
<p className="text-xs text-muted-foreground">
{new Date(audio.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(audio.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadAudio(audio)}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteAudio(audio.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{audio.input_text}
</p>
<audio
key={audio.id}
controls
className="w-full"
preload="metadata"
>
<source src={audio.audio_url} type="audio/mpeg" />
</audio>
</div>
))}
</div>
{/* Pagination */}
{!audioSearchQuery && audioHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={audioHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
);
};
+472
View File
@@ -0,0 +1,472 @@
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload, Download, Trash2, FileAudio, Copy, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { useToast } from "@/hooks/use-toast";
import { transcriptionService, TranscriptionRecord } from "@/services/transcription";
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
export const TranscriptionView = () => {
const [isTranscribing, setIsTranscribing] = useState(false);
const [lastTranscriptionResult, setLastTranscriptionResult] = useState<TranscriptionRecord | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [transcriptionHistory, setTranscriptionHistory] = useState<TranscriptionRecord[]>([]);
const [isLoadingTranscriptions, setIsLoadingTranscriptions] = useState(false);
const [transcriptionSearchQuery, setTranscriptionSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega transcrições do banco de dados
const loadTranscriptions = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingTranscriptions(true);
try {
const fetchedTranscriptions = await transcriptionService.getTranscriptions(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedTranscriptions)) {
setTranscriptionHistory(fetchedTranscriptions);
} else {
console.warn('Resposta da API não é um array:', fetchedTranscriptions);
setTranscriptionHistory([]);
}
} catch (error: any) {
console.error('Erro ao carregar transcrições:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de transcrições.",
variant: "destructive",
});
setTranscriptionHistory([]);
} finally {
setIsLoadingTranscriptions(false);
}
};
// Carrega transcrições ao montar e quando a paginação mudar
useEffect(() => {
loadTranscriptions();
}, [currentPage, perPage]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Valida o arquivo usando o serviço
const validation = transcriptionService.validateAudioFile(file);
if (!validation.valid) {
toast({
title: "Arquivo inválido",
description: validation.error,
variant: "destructive",
});
return;
}
setSelectedFile(file);
handleTranscription(file);
};
const handleTranscription = async (file: File) => {
setIsTranscribing(true);
try {
// Chama o serviço de transcrição
const response = await transcriptionService.transcribeAudio({
audioFile: file,
});
// Verifica se a transcrição foi bem-sucedida
if (response.success) {
// Cria objeto da transcrição recém-gerada para exibição imediata
const newTranscription: TranscriptionRecord = {
id: response.transcription_id,
user_email: '',
estabelecimento_id: 0,
audio_file_name: file.name,
audio_duration_seconds: 0,
transcription_text: response.message,
model: 'whisper-1',
audio_url: response.audio_url,
cost_usd: '0',
created_at: new Date().toISOString(),
};
// Salva a última transcrição para exibição
setLastTranscriptionResult(newTranscription);
toast({
title: "Transcrição concluída",
description: "Seu áudio foi transcrito com sucesso!",
});
// Recarrega a lista de transcrições (sem aguardar para não bloquear a UI)
loadTranscriptions(1, perPage);
setCurrentPage(1);
} else {
throw new Error(response.message || 'Erro ao transcrever áudio');
}
} catch (error: any) {
console.error('Erro na transcrição:', error);
toast({
title: "Erro na transcrição",
description: error.message || "Não foi possível transcrever o áudio. Tente novamente.",
variant: "destructive",
});
} finally {
setIsTranscribing(false);
}
};
const handleDeleteTranscription = () => {
setLastTranscriptionResult(null);
setSelectedFile(null);
};
const handleDeleteTranscriptionFromHistory = async (transcriptionId: string) => {
try {
const result = await transcriptionService.deleteTranscription(transcriptionId);
if (result.success) {
toast({
title: "Transcrição removida",
description: "A transcrição foi removida com sucesso.",
});
// Se a transcrição deletada for a última gerada, limpa o preview
if (lastTranscriptionResult && lastTranscriptionResult.id === transcriptionId) {
setLastTranscriptionResult(null);
}
// Recarrega a lista de transcrições
await loadTranscriptions();
} else {
throw new Error(result.message || 'Erro ao deletar transcrição');
}
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a transcrição.",
variant: "destructive",
});
}
};
const handleCopyTranscription = (text: string) => {
navigator.clipboard.writeText(text);
toast({
title: "Texto copiado",
description: "A transcrição foi copiada para a área de transferência",
});
};
const handleDownloadTranscription = (transcription: TranscriptionRecord) => {
const blob = new Blob([transcription.transcription_text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `transcricao_${transcription.audio_file_name}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Download iniciado",
description: "O arquivo de transcrição está sendo baixado.",
});
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1);
};
const filteredTranscriptions = Array.isArray(transcriptionHistory)
? transcriptionHistory.filter(item =>
item.audio_file_name.toLowerCase().includes(transcriptionSearchQuery.toLowerCase()) ||
item.transcription_text.toLowerCase().includes(transcriptionSearchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto p-3 md:p-6">
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect text-xs md:text-sm">
<TabsTrigger value="transcription" className="text-xs md:text-sm">
Transcrição
</TabsTrigger>
<TabsTrigger value="history" className="text-xs md:text-sm">
Histórico
</TabsTrigger>
</TabsList>
<TabsContent value="transcription" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<h3 className="text-lg font-semibold">
Converter Áudio em Texto
</h3>
<p className="text-sm text-muted-foreground">
Envie um arquivo de áudio para transcrição (máx. 25MB)
</p>
<p className="text-xs text-muted-foreground">
Formatos: {SUPPORTED_FORMATS.join(', ')}
</p>
<input
type="file"
id="audio-upload"
className="hidden"
accept={SUPPORTED_FORMATS.map(f => `.${f}`).join(',')}
onChange={handleFileSelect}
/>
<label
htmlFor="audio-upload"
className="border-2 border-dashed border-border rounded-xl p-12 text-center space-y-4 hover:border-primary/50 transition-colors cursor-pointer block"
>
<div className="w-16 h-16 mx-auto rounded-full bg-primary/10 flex items-center justify-center">
{selectedFile ? (
<FileAudio className="w-8 h-8 text-primary" />
) : (
<Upload className="w-8 h-8 text-primary" />
)}
</div>
<div>
<p className="font-medium">
{selectedFile ? selectedFile.name : "Clique para selecionar ou arraste o arquivo"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{selectedFile
? `${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
: "Máximo 25 MB"
}
</p>
</div>
{!selectedFile && (
<Button variant="outline" className="gap-2" type="button">
<Upload className="w-4 h-4" />
Selecionar Arquivo
</Button>
)}
</label>
{isTranscribing && (
<div className="bg-muted/30 rounded-lg p-6 flex items-center justify-center gap-3">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-sm font-medium">
Transcrevendo áudio...
</p>
</div>
)}
</div>
{lastTranscriptionResult && (
<div className="glass-effect rounded-xl p-6 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold">{lastTranscriptionResult.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
Transcrito {new Date(lastTranscriptionResult.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleCopyTranscription(lastTranscriptionResult.transcription_text)}
>
<Copy className="w-3 h-3" />
Copiar
</Button>
<Button
size="sm"
variant="outline"
className="gap-1"
onClick={() => handleDownloadTranscription(lastTranscriptionResult)}
>
<Download className="w-3 h-3" />
Baixar
</Button>
<Button
size="sm"
variant="destructive"
className="gap-1"
onClick={handleDeleteTranscription}
>
<Trash2 className="w-3 h-3" />
Excluir
</Button>
</div>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{lastTranscriptionResult.transcription_text}
</p>
</div>
</div>
)}
</TabsContent>
<TabsContent value="history" className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={transcriptionSearchQuery}
onChange={(e) => setTranscriptionSearchQuery(e.target.value)}
placeholder="Buscar por nome ou conteúdo..."
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading State */}
{isLoadingTranscriptions ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando transcrições...</p>
</div>
) : (
<>
{filteredTranscriptions.length === 0 ? (
<div className="glass-effect rounded-xl p-12 text-center">
<FileAudio className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<p className="text-muted-foreground">
{transcriptionSearchQuery ? 'Nenhuma transcrição encontrada' : 'Nenhuma transcrição no histórico'}
</p>
</div>
) : (
<>
<div className="space-y-3">
{filteredTranscriptions.map((item) => (
<div key={item.id} className="glass-effect rounded-lg p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate">{item.audio_file_name}</h4>
<p className="text-xs text-muted-foreground">
{new Date(item.created_at).toLocaleDateString('pt-BR')} às{' '}
{new Date(item.created_at).toLocaleTimeString('pt-BR')}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleCopyTranscription(item.transcription_text)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadTranscription(item)}
>
<Download className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteTranscriptionFromHistory(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{item.transcription_text}
</p>
</div>
))}
</div>
{/* Pagination */}
{!transcriptionSearchQuery && transcriptionHistory.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={transcriptionHistory.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
);
};
+24 -21
View File
@@ -95,19 +95,19 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="border-b border-border p-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" />
<h2 className="text-xl md:text-2xl font-bold flex items-center gap-2">
<Bot className="w-5 h-5 md:w-6 md:h-6" />
Gerenciador de Bots
</h2>
<p className="text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
<p className="text-sm md:text-base text-muted-foreground mt-1">Crie e gerencie seus assistentes de IA</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button>
<Button className="w-full md:w-auto text-sm">
<Bot className="w-4 h-4 mr-2" />
Criar Bot
</Button>
@@ -167,7 +167,7 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
</div>
</div>
<div className="flex-1 overflow-auto p-6">
<div className="flex-1 overflow-auto p-3 md:p-6">
{bots.length === 0 ? (
<Card className="max-w-2xl mx-auto mt-12">
<CardHeader>
@@ -190,45 +190,48 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
</CardContent>
</Card>
) : (
<div className="max-w-6xl mx-auto">
<div className="max-w-6xl mx-auto overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Modelo</TableHead>
<TableHead className="text-right">Ações</TableHead>
<TableHead className="text-xs md:text-sm">Nome</TableHead>
<TableHead className="hidden md:table-cell text-sm">Modelo</TableHead>
<TableHead className="text-right text-xs md:text-sm">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentBots.map((bot) => (
<TableRow key={bot.id}>
<TableCell className="font-medium">{bot.name}</TableCell>
<TableCell>{bot.model}</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{bot.name}</TableCell>
<TableCell className="hidden md:table-cell text-sm">{bot.model}</TableCell>
<TableCell className="text-right">
<div className="flex gap-2 justify-end">
<div className="flex gap-1 justify-end flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => onStartChat(bot)}
className="text-xs md:text-sm"
>
<MessageSquare className="w-4 h-4 mr-2" />
Conversar
<MessageSquare className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
<span className="hidden md:inline">Conversar</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openEditDialog(bot)}
className="text-xs md:text-sm"
>
<Edit className="w-4 h-4 mr-2" />
Editar
<Edit className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
<span className="hidden md:inline">Editar</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openDeleteDialog(bot)}
className="text-xs md:text-sm"
>
<Trash2 className="w-4 h-4 mr-2" />
Excluir
<Trash2 className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
<span className="hidden md:inline">Excluir</span>
</Button>
</div>
</TableCell>
+61 -26
View File
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState, useRef, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { chatService } from "@/services/chat";
interface ChatInputProps {
onSendMessage: (message: string, files?: File[]) => void;
@@ -67,21 +68,54 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const newFiles = [...attachedFiles, ...files];
// Validate file size (max 10MB per file)
const validFiles = files.filter((file) => {
// Valida quantidade máxima de anexos (5)
if (newFiles.length > chatService.getMaxAttachments()) {
toast({
title: "Limite de anexos excedido",
description: `Você pode anexar no máximo ${chatService.getMaxAttachments()} arquivos por mensagem.`,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Valida tipos de arquivo e tamanho
const validFiles: File[] = [];
for (const file of files) {
// Valida tamanho (max 10MB por arquivo)
if (file.size > 10 * 1024 * 1024) {
toast({
title: "Arquivo muito grande",
description: `${file.name} excede o limite de 10MB`,
variant: "destructive",
});
return false;
continue;
}
return true;
});
setAttachedFiles([...attachedFiles, ...validFiles]);
validFiles.push(file);
}
// Valida formatos permitidos
const allFiles = [...attachedFiles, ...validFiles];
const validation = chatService.validateAttachments(allFiles);
if (!validation.valid) {
toast({
title: "Formato de arquivo não permitido",
description: validation.error,
variant: "destructive",
});
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
setAttachedFiles(allFiles);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
@@ -92,19 +126,19 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
};
return (
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-4">
<div className="max-w-4xl mx-auto space-y-3">
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3">
{/* Attached Files Preview */}
{attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-1 md:gap-2">
{attachedFiles.map((file, index) => (
<div
key={index}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-muted border border-border text-sm"
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"
>
<Paperclip className="w-3 h-3 text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
<span className="hidden md:inline text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
<Button
@@ -121,12 +155,12 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
)}
{/* Message Input and Buttons */}
<div className="flex gap-2 items-end">
<div className="flex gap-1 md:gap-2 items-end">
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
{/* Resize Handle */}
{/* Resize Handle - Hidden on mobile */}
<div
onMouseDown={handleMouseDown}
className={`w-full h-6 flex items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
isDragging ? 'bg-primary/20' : ''
}`}
title="Arraste para cima para aumentar"
@@ -135,51 +169,52 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
</div>
{/* Textarea */}
<div className="px-3 pb-3">
<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: `${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"
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>
{/* Action Buttons */}
<div className="flex gap-2">
<div className="flex gap-1 md:gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp"
accept={chatService.getAllowedFileExtensions()}
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
/>
<Button
variant="outline"
size="icon"
className="h-11 w-11 glass-effect"
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
onClick={() => fileInputRef.current?.click()}
title="Anexar arquivo"
>
<Paperclip className="w-5 h-5" />
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
</Button>
<Button
onClick={handleSend}
size="icon"
className="h-11 w-11 cyber-glow"
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
disabled={!message.trim() && attachedFiles.length === 0}
>
<Send className="w-5 h-5" />
<Send className="w-4 h-4 md:w-5 md:h-5" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
Pressione Enter para enviar, Shift + Enter para nova linha Máx. 10MB por arquivo
<p className="hidden md:block text-xs text-muted-foreground text-center">
Pressione Enter para enviar, Shift + Enter para nova linha Máx. {chatService.getMaxAttachments()} anexos Formatos: {chatService.getAllowedFileTypesLabel()}
</p>
</div>
</div>
+10 -10
View File
@@ -30,22 +30,22 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
return (
<div
className={`flex gap-4 py-6 px-6 group hover:bg-muted/10 transition-colors ${
className={`flex gap-2 md:gap-4 py-4 md:py-6 px-3 md:px-6 group hover:bg-muted/10 transition-colors ${
isAssistant ? "justify-start" : "justify-end"
}`}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{isAssistant && (
<div className="flex flex-col items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
<Bot className="w-5 h-5 text-primary-foreground" />
<div className="flex flex-col items-center gap-1 md:gap-2">
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-gradient-to-br from-primary to-secondary">
<Bot className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
</div>
{model && (
<Badge
variant="secondary"
className="text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
className="hidden md:block text-xs px-2 py-0.5 bg-primary/10 text-primary border border-primary/30"
>
{model}
</Badge>
@@ -53,7 +53,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div>
)}
<div className={`flex-1 max-w-3xl space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
<div className={`flex-1 max-w-3xl space-y-1 md:space-y-2 ${isAssistant ? "" : "flex flex-col items-end"}`}>
{/* Attachments */}
{attachments && attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
@@ -73,13 +73,13 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
)}
<div
className={`inline-block px-4 py-3 rounded-xl ${
className={`inline-block px-3 md:px-4 py-2 md:py-3 rounded-xl ${
isAssistant
? "bg-card border border-border"
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
}`}
>
<p className={`leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}>
<p className={`text-sm md:text-base leading-relaxed ${isAssistant ? "text-foreground" : "text-white"}`}>
{content}
</p>
</div>
@@ -100,8 +100,8 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
</div>
{!isAssistant && (
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
<User className="w-5 h-5 text-accent" />
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-accent">
<User className="w-4 h-4 md:w-5 md:h-5 text-accent" />
</div>
)}
</div>
+355 -231
View File
@@ -1,8 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
import { chatService, ChatRecord, FolderRecord } from "@/services/chat";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,115 +34,159 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Conversation {
id: string;
title: string;
lastMessage: string;
timestamp: string;
folderId?: string;
}
interface ChatFolder {
id: string;
name: string;
}
interface ChatSidebarProps {
isCollapsed: boolean;
onToggleCollapse: () => void;
onNewChat: () => void;
onSelectChat?: (chat: ChatRecord) => void;
currentChatId?: string;
}
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSidebarProps) => {
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
const { toast } = useToast();
const [searchQuery, setSearchQuery] = useState("");
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
const [renamingFolder, setRenamingFolder] = useState<FolderRecord | null>(null);
const [renamedFolderName, setRenamedFolderName] = useState("");
const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [folders, setFolders] = useState<ChatFolder[]>([
{ id: "work", name: "Trabalho" },
{ id: "personal", name: "Pessoal" },
]);
// Estado carregado do banco de dados via chatService
const [chats, setChats] = useState<ChatRecord[]>([]);
const [folders, setFolders] = useState<FolderRecord[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [conversations, setConversations] = useState<Conversation[]>([
{
id: "1",
title: "Conversa sobre IA",
lastMessage: "Como funciona o machine learning?",
timestamp: "Há 5 min",
},
{
id: "2",
title: "Projeto HGTX",
lastMessage: "Discutindo arquitetura do sistema",
timestamp: "Há 1 hora",
folderId: "work",
},
{
id: "3",
title: "Ideias criativas",
lastMessage: "Brainstorm para novo produto",
timestamp: "Ontem",
folderId: "personal",
},
]);
// Carrega chats e pastas do banco de dados quando o componente monta
useEffect(() => {
loadChatsAndFolders();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
// Escuta evento customizado para recarregar quando chat é atualizado
const handleChatUpdate = () => {
loadChatsAndFolders();
};
const handleCreateFolder = () => {
window.addEventListener('chatUpdated', handleChatUpdate);
// Cleanup
return () => {
window.removeEventListener('chatUpdated', handleChatUpdate);
};
}, []);
const loadChatsAndFolders = async () => {
setIsLoading(true);
try {
const data = await chatService.getChatsAndFolders();
console.log('Dados carregados:', data);
setChats(data.chats);
setFolders(data.folders);
// Expande todas as pastas por padrão
setExpandedFolders(new Set(data.folders.map(f => f.id)));
} catch (error: any) {
console.error('Erro ao carregar chats e pastas:', error);
toast({
title: "Erro ao carregar dados",
description: error.message || "Não foi possível carregar chats e pastas.",
variant: "destructive",
});
// Define arrays vazios em caso de erro
setChats([]);
setFolders([]);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (newFolderName.trim()) {
const newFolder: ChatFolder = {
id: Date.now().toString(),
name: newFolderName,
};
setFolders([...folders, newFolder]);
setNewFolderName("");
setIsCreateFolderOpen(false);
try {
await chatService.createFolder(newFolderName);
await loadChatsAndFolders();
setNewFolderName("");
setIsCreateFolderOpen(false);
toast({
title: "Pasta criada",
description: `Pasta "${newFolderName}" criada com sucesso.`,
});
} catch (error: any) {
console.error('Erro ao criar pasta:', error);
toast({
title: "Erro ao criar pasta",
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const handleEditFolder = () => {
if (editingFolder && newFolderName.trim()) {
setFolders(
folders.map((f) =>
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
)
);
setNewFolderName("");
setEditingFolder(null);
setIsEditFolderOpen(false);
}
};
const handleDeleteFolder = () => {
const handleDeleteFolder = async () => {
if (deletingFolder) {
// Remove folder and move conversations back to "no folder"
setConversations(
conversations.map((conv) =>
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
)
);
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
try {
await chatService.deleteFolder(deletingFolder.id);
await loadChatsAndFolders();
setDeletingFolder(null);
setIsDeleteFolderOpen(false);
toast({
title: "Pasta excluída",
description: "A pasta foi excluída com sucesso.",
});
} catch (error: any) {
console.error('Erro ao excluir pasta:', error);
toast({
title: "Erro ao excluir pasta",
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const openEditFolder = (folder: ChatFolder) => {
setEditingFolder(folder);
setNewFolderName(folder.name);
setIsEditFolderOpen(true);
};
const openDeleteFolder = (folder: ChatFolder) => {
const openDeleteFolder = (folder: FolderRecord) => {
setDeletingFolder(folder);
setIsDeleteFolderOpen(true);
};
const openRenameFolder = (folder: FolderRecord) => {
setRenamingFolder(folder);
setRenamedFolderName(folder.name);
setIsRenameFolderOpen(true);
};
const handleRenameFolder = async () => {
if (renamingFolder && renamedFolderName.trim()) {
try {
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
await loadChatsAndFolders();
setRenamingFolder(null);
setRenamedFolderName("");
setIsRenameFolderOpen(false);
toast({
title: "Pasta renomeada",
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
});
} catch (error: any) {
console.error('Erro ao renomear pasta:', error);
toast({
title: "Erro ao renomear pasta",
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
variant: "destructive",
});
}
}
};
const toggleFolder = (folderId: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderId)) {
@@ -149,37 +197,79 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
setExpandedFolders(newExpanded);
};
const moveToFolder = (conversationId: string, folderId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId } : conv
)
);
const moveToFolder = async (chatId: string, folderId: string) => {
try {
await chatService.moveChatToFolder(chatId, folderId);
await loadChatsAndFolders();
const folder = folders.find(f => f.id === folderId);
toast({
title: "Chat movido",
description: `Movido para a pasta "${folder?.name}".`,
});
} catch (error: any) {
console.error('Erro ao mover chat:', error);
toast({
title: "Erro ao mover chat",
description: error.message || "Não foi possível mover o chat. Tente novamente.",
variant: "destructive",
});
}
};
const removeFromFolder = (conversationId: string) => {
setConversations(
conversations.map((conv) =>
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
)
);
const handleDeleteChat = async () => {
if (deletingChat) {
try {
await chatService.deleteChat(deletingChat.id);
await loadChatsAndFolders();
setDeletingChat(null);
setIsDeleteChatOpen(false);
toast({
title: "Chat excluído",
description: "A conversa foi excluída com sucesso.",
});
} catch (error: any) {
console.error('Erro ao excluir chat:', error);
toast({
title: "Erro ao excluir chat",
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
variant: "destructive",
});
}
}
};
const filteredConversations = conversations.filter(
(conv) =>
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
);
const openDeleteChat = (chat: ChatRecord) => {
setDeletingChat(chat);
setIsDeleteChatOpen(true);
};
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
const conversationsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
const handleSelectChat = (chat: ChatRecord) => {
if (onSelectChat) {
onSelectChat(chat);
}
};
// Filtrar chats pela busca
const filteredChats = chats.filter((chat) => {
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);
// Agrupar chats por pasta (quando folder_id === folder.id)
const chatsByFolder = folders.reduce((acc, folder) => {
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
return acc;
}, {} as Record<string, Conversation[]>);
}, {} as Record<string, ChatRecord[]>);
if (isCollapsed) {
return (
<div className="w-12 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
<Button
variant="ghost"
size="icon"
@@ -193,7 +283,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
}
return (
<div className="w-80 border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
{/* Header with collapse button */}
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
@@ -261,33 +351,34 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
</DialogContent>
</Dialog>
{/* Edit Folder Dialog */}
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}>
{/* Rename Folder Dialog */}
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
<DialogContent className="glass-effect bg-card border-border z-50">
<DialogHeader>
<DialogTitle>Editar Pasta</DialogTitle>
<DialogTitle>Renomear Pasta</DialogTitle>
<DialogDescription>
Renomeie sua pasta de conversas.
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="edit-folder-name">Nome da Pasta</Label>
<Label htmlFor="rename-folder-name">Novo Nome</Label>
<Input
id="edit-folder-name"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Digite o novo nome..."
onKeyDown={(e) => e.key === "Enter" && handleEditFolder()}
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={() => setIsEditFolderOpen(false)}>
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
Cancelar
</Button>
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}>
Salvar
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
Renomear
</Button>
</DialogFooter>
</DialogContent>
@@ -299,7 +390,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
<AlertDialogHeader>
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. As conversas dentro da pasta serão movidas para "Sem Pasta".
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -315,131 +406,175 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
{/* Conversations List */}
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{/* Folders */}
{folders.map((folder) => (
<div key={folder.id} className="space-y-1">
<div className="flex items-center gap-1">
<button
onClick={() => toggleFolder(folder.id)}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
<Folder className="w-4 h-4 text-white" />
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
) : (
<>
{/* Folders */}
{folders.map((folder) => (
<div key={folder.id} className="space-y-1">
<div className="flex items-center gap-1">
<button
onClick={() => toggleFolder(folder.id)}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
<Folder className="w-4 h-4 text-white" />
</div>
<span className="font-medium text-sm flex-1 text-left">
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{chatsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem
className="gap-2"
onClick={() => openRenameFolder(folder)}
>
<Edit className="w-3 h-3" />
Renomear Pasta
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={() => openDeleteFolder(folder)}
>
<Trash2 className="w-3 h-3" />
Excluir Pasta
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<span className="font-medium text-sm flex-1 text-left">
{folder.name}
</span>
<span className="text-xs text-muted-foreground">
{conversationsByFolder[folder.id]?.length || 0}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem
className="gap-2"
onClick={() => openEditFolder(folder)}
>
<Edit className="w-3 h-3" />
Editar Nome
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={() => openDeleteFolder(folder)}
>
<Trash2 className="w-3 h-3" />
Excluir Pasta
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
<div className="ml-6 space-y-1">
{chatsByFolder[folder.id].map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
)}
</div>
))}
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
<div className="ml-6 space-y-1">
{conversationsByFolder[folder.id].map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
{/* Chats without folder */}
{chatsWithoutFolder.length > 0 && (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Sem Pasta
</div>
{chatsWithoutFolder.map((chat) => (
<ChatItem
key={chat.id}
chat={chat}
isSelected={currentChatId === chat.id}
onSelect={() => handleSelectChat(chat)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
onDelete={() => openDeleteChat(chat)}
/>
))}
</div>
)}
</div>
))}
{/* Conversations without folder */}
{conversationsWithoutFolder.length > 0 && (
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Sem Pasta
</div>
{conversationsWithoutFolder.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isSelected={selectedConversation === conv.id}
onSelect={() => setSelectedConversation(conv.id)}
folders={folders}
onMoveToFolder={moveToFolder}
onRemoveFromFolder={removeFromFolder}
/>
))}
</div>
{/* Empty state */}
{!isLoading && chats.length === 0 && (
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
<p className="text-sm text-muted-foreground">
Nenhuma conversa ainda
</p>
<p className="text-xs text-muted-foreground mt-1">
Clique em "Novo Chat" para começar
</p>
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* Delete Chat Alert */}
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
<AlertDialogContent className="glass-effect bg-card border-border z-50">
<AlertDialogHeader>
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
interface ConversationItemProps {
conversation: Conversation;
interface ChatItemProps {
chat: ChatRecord;
isSelected: boolean;
onSelect: () => void;
folders: ChatFolder[];
onMoveToFolder: (conversationId: string, folderId: string) => void;
onRemoveFromFolder: (conversationId: string) => void;
folders: FolderRecord[];
onMoveToFolder: (chatId: string, folderId: string) => void;
onDelete: () => void;
}
const ConversationItem = ({
conversation,
const ChatItem = ({
chat,
isSelected,
onSelect,
folders,
onMoveToFolder,
onRemoveFromFolder,
}: ConversationItemProps) => {
onDelete,
}: ChatItemProps) => {
// Formata timestamp relativo
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
addSuffix: true,
locale: ptBR,
});
return (
<div
className={`group flex items-start gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
isSelected
? "bg-sidebar-accent cyber-border"
: "hover:bg-muted/50"
}`}
onClick={onSelect}
>
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{conversation.title}</p>
<p className="text-xs text-muted-foreground truncate">
{conversation.lastMessage}
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0 max-w-[180px]">
<p className="text-sm font-medium truncate" title={chat.title}>
{chat.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conversation.timestamp}
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
{timeAgo}
</p>
</div>
@@ -448,39 +583,28 @@ const ConversationItem = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
<DropdownMenuItem className="gap-2">
<Edit className="w-3 h-3" />
Renomear
</DropdownMenuItem>
{conversation.folderId && (
<DropdownMenuItem
className="gap-2"
onClick={() => onRemoveFromFolder(conversation.id)}
>
<FolderInput className="w-3 h-3" />
Remover da Pasta
</DropdownMenuItem>
)}
{folders.map((folder) => (
{/* Opções para mover para pastas */}
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
<DropdownMenuItem
key={folder.id}
className="gap-2"
onClick={() => onMoveToFolder(conversation.id, folder.id)}
onClick={() => onMoveToFolder(chat.id, folder.id)}
>
<Folder className="w-3 h-3" />
Mover para {folder.name}
</DropdownMenuItem>
))}
<DropdownMenuItem className="gap-2 text-destructive">
<DropdownMenuItem
className="gap-2 text-destructive"
onClick={onDelete}
>
<Trash2 className="w-3 h-3" />
Excluir
</DropdownMenuItem>
+184 -24
View File
@@ -1,9 +1,15 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
import { useToast } from "@/hooks/use-toast";
import { getModelId } from "@/config/models";
// Texto padrão da personalidade (usado para novos chats)
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
interface Message {
id: string;
@@ -18,24 +24,64 @@ interface Message {
}
export const ChatView = () => {
const { toast } = useToast();
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1");
const [currentChatId, setCurrentChatId] = useState("1");
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
const [selectedModel, setSelectedModel] = useState("GPT-4o");
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
const [currentChatId, setCurrentChatId] = useState("0");
// Inicia com texto padrão - será atualizado ao carregar chat existente
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: "ChatGPT 4.1",
model: "GPT-4o",
},
]);
const [isLoading, setIsLoading] = useState(false);
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
// quando enviadas via handleSendMessage
// useEffect(() => {
// if (messages.length > 1) {
// saveCurrentChat();
// window.dispatchEvent(new Event('chatUpdated'));
// }
// }, [messages]);
// NOTA: Função de salvamento localStorage desabilitada - migrado para banco de dados
// const saveCurrentChat = () => {
// try {
// const chatTitle = chatService.generateChatTitle(
// messages.find(m => m.role === 'user')?.content || 'Nova Conversa'
// );
// const storedChat: StoredChat = {
// id: currentChatId,
// title: chatTitle,
// createdAt: new Date(),
// updatedAt: new Date(),
// model: selectedModel,
// systemPrompt: systemPrompt,
// messages: messages.map(msg => ({
// ...msg,
// timestamp: new Date(),
// })),
// };
// chatService.saveChat(storedChat);
// } catch (error) {
// console.error('Erro ao salvar chat:', error);
// }
// };
const handleNewChat = () => {
const newChatId = Date.now().toString();
setCurrentChatId(newChatId);
// Reseta para "0" - novo chat sempre começa com chat_id "0"
setCurrentChatId("0");
// Reseta a personalidade para o texto padrão
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
setMessages([
{
id: "1",
@@ -44,9 +90,67 @@ export const ChatView = () => {
model: selectedModel,
},
]);
// Dispara evento para atualizar a sidebar
window.dispatchEvent(new Event('chatUpdated'));
};
const handleSendMessage = (content: string, files?: File[]) => {
const handleLoadChat = async (chat: ChatRecord) => {
// Carrega um chat existente do banco de dados
setCurrentChatId(chat.id);
// Atualiza a personalidade com o valor do banco de dados
// Se não tiver personalidade salva, usa o texto padrão
setSystemPrompt(chat.personalidade || DEFAULT_SYSTEM_PROMPT);
// Limpa mensagens enquanto carrega
setMessages([]);
setIsLoading(true);
try {
// Busca mensagens do chat na API
const messagesFromAPI = await chatService.getChatMessages(chat.id);
// Converte MessageRecord para Message
const loadedMessages: Message[] = messagesFromAPI.map((msg: MessageRecord) => ({
id: msg.id,
role: msg.role,
content: msg.content,
model: msg.model_name, // Nome do modelo retornado pela API
attachments: msg.has_attachments ? [] : undefined, // Não temos detalhes dos anexos no GET
}));
setMessages(loadedMessages);
toast({
title: "Chat carregado",
description: `${loadedMessages.length} mensagens carregadas.`,
});
} catch (error: any) {
console.error('Erro ao carregar mensagens:', error);
toast({
title: "Erro ao carregar chat",
description: error.message || "Não foi possível carregar as mensagens.",
variant: "destructive",
});
// Inicia com mensagem padrão em caso de erro
setMessages([
{
id: "1",
role: "assistant",
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
model: selectedModel,
},
]);
} finally {
setIsLoading(false);
}
};
const handleSendMessage = async (content: string, files?: File[]) => {
// Adiciona mensagem do usuário na interface
const newMessage: Message = {
id: Date.now().toString(),
role: "user",
@@ -61,31 +165,87 @@ export const ChatView = () => {
setMessages((prev) => [...prev, newMessage]);
setIsLoading(true);
// Simulate AI response
setTimeout(() => {
const aiResponse: Message = {
try {
// Envia mensagem para a API
// Converte o nome do modelo para o ID da API
const modelId = getModelId(selectedModel);
const response = await chatService.sendMessage({
chatId: currentChatId,
message: content,
modelId: modelId,
personalidade: systemPrompt,
anexos: files,
});
// Adiciona resposta da IA na interface
if (response.success) {
// IMPORTANTE: Atualiza o chat_id com o valor retornado pela API
// Isso mantém o contexto da conversa para as próximas mensagens
if (response.chat_id && response.chat_id !== currentChatId) {
console.log(`Chat ID atualizado: ${currentChatId}${response.chat_id}`);
// Se estava com chat_id "0", significa que é a primeira mensagem
// e o chat acabou de ser criado no backend
const isFirstMessage = currentChatId === "0";
setCurrentChatId(response.chat_id);
// Dispara evento para ChatSidebar recarregar e mostrar o novo chat
if (isFirstMessage) {
console.log('Primeira mensagem - novo chat criado, atualizando sidebar');
window.dispatchEvent(new Event('chatUpdated'));
}
}
const aiResponse: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: response.response,
model: selectedModel,
};
setMessages((prev) => [...prev, aiResponse]);
} else {
throw new Error('Resposta inválida da API');
}
} catch (error: any) {
console.error('Erro ao enviar mensagem:', error);
// Mostra toast de erro para o usuário
toast({
title: "Erro ao enviar mensagem",
description: error.message || "Não foi possível comunicar com a IA. Tente novamente.",
variant: "destructive",
});
// Adiciona mensagem de erro na interface
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content:
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
content: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
model: selectedModel,
};
setMessages((prev) => [...prev, aiResponse]);
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}, 1500);
}
};
return (
<div className="flex h-full">
{/* Chat Sidebar */}
<ChatSidebar
isCollapsed={isChatSidebarCollapsed}
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
onNewChat={handleNewChat}
/>
<div className="flex h-full w-full pb-16 md:pb-0">
{/* Chat Sidebar - Hidden on mobile */}
<div className="hidden md:block">
<ChatSidebar
isCollapsed={isChatSidebarCollapsed}
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
onNewChat={handleNewChat}
onSelectChat={handleLoadChat}
currentChatId={currentChatId}
/>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col w-full">
<ChatHeader
showModelSelector={true}
selectedModel={selectedModel}
@@ -95,7 +255,7 @@ export const ChatView = () => {
/>
<ScrollArea className="flex-1">
<div className="max-w-4xl mx-auto">
<div className="max-w-4xl mx-auto px-2 md:px-4">
{messages.map((message) => (
<ChatMessage
key={message.id}
+371 -94
View File
@@ -1,8 +1,8 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { ChatHeader } from "@/components/ChatHeader";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Download, Trash2, Sparkles, Clock, Search } from "lucide-react";
import { Download, Trash2, Sparkles, Clock, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import {
@@ -13,82 +13,226 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface GeneratedImage {
id: string;
url: string;
prompt: string;
size: string;
timestamp: string;
}
import { useToast } from "@/hooks/use-toast";
import {
imageGenerationService,
IMAGE_SIZE_OPTIONS,
ImageSize,
ImageRecord
} from "@/services/imageGeneration";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
} from "@/components/ui/pagination";
export const ImageView = () => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [selectedSize, setSelectedSize] = useState("1024x1024");
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
const [searchQuery, setSearchQuery] = useState("");
const [images, setImages] = useState<GeneratedImage[]>([
{
id: "1",
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
prompt: "Paisagem futurista com cidades voadoras",
size: "1024x1024",
timestamp: "Há 2 horas",
},
{
id: "2",
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop",
prompt: "Robô humanoide em estilo cyberpunk",
size: "1024x1536",
timestamp: "Há 5 horas",
},
]);
const [images, setImages] = useState<ImageRecord[]>([]);
const [lastGeneratedImage, setLastGeneratedImage] = useState<ImageRecord | null>(null);
const [isLoadingImages, setIsLoadingImages] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { toast } = useToast();
// Carrega imagens do banco de dados ao montar o componente
const loadImages = async (page: number = currentPage, limit: number = perPage) => {
setIsLoadingImages(true);
try {
const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
// Garante que sempre seja um array
if (Array.isArray(fetchedImages)) {
setImages(fetchedImages);
} else {
console.warn('Resposta da API não é um array:', fetchedImages);
setImages([]);
}
} catch (error: any) {
console.error('Erro ao carregar imagens:', error);
toast({
title: "Erro ao carregar histórico",
description: error.message || "Não foi possível carregar o histórico de imagens.",
variant: "destructive",
});
setImages([]);
} finally {
setIsLoadingImages(false);
}
};
// Carrega imagens ao montar e quando a paginação mudar
useEffect(() => {
loadImages();
}, [currentPage, perPage]);
const handleGenerate = async () => {
// Valida a descrição antes de enviar
const validation = imageGenerationService.validateDescription(prompt);
if (!validation.valid) {
toast({
title: "Descrição inválida",
description: validation.error,
variant: "destructive",
});
return;
}
const handleGenerate = () => {
setIsGenerating(true);
// Simulate image generation
setTimeout(() => {
const newImage: GeneratedImage = {
id: Date.now().toString(),
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
prompt: prompt,
try {
// Chama o serviço de geração de imagem
const response = await imageGenerationService.generateImage({
description: prompt,
size: selectedSize,
timestamp: "Agora",
};
setImages([newImage, ...images]);
});
// Verifica se a geração foi bem-sucedida
if (response.success && response.image_url && response.image_generation_id) {
toast({
title: "Imagem gerada com sucesso",
description: `Tamanho: ${IMAGE_SIZE_OPTIONS[selectedSize].label}`,
});
// Cria objeto da imagem recém-gerada para exibição imediata
const newGeneratedImage: ImageRecord = {
id: response.image_generation_id,
user_email: '', // Será preenchido pelo backend
estabelecimento_id: 0, // Será preenchido pelo backend
description: response.message, // Descrição original
model: 'dall-e-3', // Modelo padrão
image_url: response.image_url,
size: selectedSize,
cost_usd: '0',
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
created_at: new Date().toISOString(),
};
// Salva a última imagem gerada para exibição
setLastGeneratedImage(newGeneratedImage);
// Limpa o campo de descrição após sucesso
setPrompt("");
// Recarrega a lista de imagens (sem aguardar para não bloquear a UI)
loadImages(1, perPage);
setCurrentPage(1);
} else {
// Se success for false ou faltarem dados, trata como erro
throw {
message: response.message || 'Erro ao gerar imagem',
code: response.code,
};
}
} catch (error: any) {
console.error('Erro na geração de imagem:', error);
// Exibe a mensagem de erro exata retornada pela API
const errorMessage = error.message || "Não foi possível gerar a imagem. Tente novamente.";
const errorCode = error.code ? ` (${error.code})` : '';
toast({
title: "Erro na geração",
description: errorMessage + errorCode,
variant: "destructive",
});
} finally {
setIsGenerating(false);
setPrompt("");
}, 3000);
}
};
const handleDelete = (id: string) => {
setImages(images.filter((img) => img.id !== id));
const handleDelete = async (id: string) => {
try {
const result = await imageGenerationService.deleteImage(id);
if (result.success) {
toast({
title: "Imagem removida",
description: "A imagem foi removida com sucesso.",
});
// Se a imagem deletada for a última gerada, limpa o preview
if (lastGeneratedImage && lastGeneratedImage.id === id) {
setLastGeneratedImage(null);
}
// Recarrega a lista de imagens
await loadImages();
} else {
throw new Error(result.message || 'Erro ao deletar imagem');
}
} catch (error: any) {
console.error('Erro ao deletar imagem:', error);
toast({
title: "Erro ao remover",
description: error.message || "Não foi possível remover a imagem.",
variant: "destructive",
});
}
};
const filteredImages = images.filter((img) =>
img.prompt.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDownload = async (image: ImageRecord) => {
try {
await imageGenerationService.downloadImage(
image.image_url,
`${image.description.substring(0, 30)}_${image.size}.png`
);
toast({
title: "Download iniciado",
description: "A imagem está sendo baixada.",
});
} catch (error) {
toast({
title: "Erro no download",
description: "Não foi possível baixar a imagem.",
variant: "destructive",
});
}
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handlePerPageChange = (value: string) => {
setPerPage(parseInt(value));
setCurrentPage(1); // Reset para primeira página ao mudar itens por página
};
const filteredImages = Array.isArray(images)
? images.filter((img) =>
img.description.toLowerCase().includes(searchQuery.toLowerCase())
)
: [];
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full pb-16 md:pb-0">
<ChatHeader />
<div className="flex-1 flex overflow-hidden">
{/* Main Content */}
<ScrollArea className="flex-1">
<div className="max-w-5xl mx-auto p-6">
<Tabs defaultValue="generate" className="space-y-6">
<div className="max-w-5xl mx-auto p-3 md:p-6">
<Tabs defaultValue="generate" className="space-y-4 md:space-y-6">
<TabsList className="grid w-full grid-cols-2 glass-effect">
<TabsTrigger value="generate">Gerar Imagem</TabsTrigger>
<TabsTrigger value="history">
<Clock className="w-4 h-4 mr-2" />
Histórico
<TabsTrigger value="generate" className="text-xs md:text-sm">Gerar Imagem</TabsTrigger>
<TabsTrigger value="history" className="text-xs md:text-sm">
<Clock className="w-3 h-3 md:w-4 md:h-4 mr-1 md:mr-2" />
<span className="hidden md:inline">Histórico</span>
<span className="md:hidden">Hist.</span>
</TabsTrigger>
</TabsList>
{/* Generate Tab */}
<TabsContent value="generate" className="space-y-6">
<div className="glass-effect rounded-xl p-6 space-y-4">
<TabsContent value="generate" className="space-y-4 md:space-y-6">
<div className="glass-effect rounded-xl p-4 md:p-6 space-y-3 md:space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Descreva a imagem
@@ -101,19 +245,19 @@ export const ImageView = () => {
/>
</div>
<div className="flex gap-4 items-end">
<div className="flex flex-col md:flex-row gap-3 md:gap-4 md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium text-foreground">
Tamanho
</label>
<Select value={selectedSize} onValueChange={setSelectedSize}>
<Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem>
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem>
<SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
<SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -149,12 +293,13 @@ export const ImageView = () => {
)}
{/* Recent Images Preview */}
{!isGenerating && images.length > 0 && (
{!isGenerating && lastGeneratedImage && (
<div>
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
<ImageCard
image={images[0]}
image={lastGeneratedImage}
onDelete={handleDelete}
onDownload={handleDownload}
/>
</div>
)}
@@ -162,35 +307,102 @@ export const ImageView = () => {
{/* History Tab */}
<TabsContent value="history" className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar por descrição..."
className="pl-9"
/>
{/* Search and Filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar por descrição..."
className="pl-9"
/>
</div>
<Select value={perPage.toString()} onValueChange={handlePerPageChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-effect bg-popover border-border z-50">
<SelectItem value="5">5 por página</SelectItem>
<SelectItem value="10">10 por página</SelectItem>
<SelectItem value="20">20 por página</SelectItem>
<SelectItem value="50">50 por página</SelectItem>
</SelectContent>
</Select>
</div>
{/* Images Grid */}
{filteredImages.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredImages.map((image) => (
<ImageCard
key={image.id}
image={image}
onDelete={handleDelete}
/>
))}
{/* Loading State */}
{isLoadingImages ? (
<div className="glass-effect rounded-xl p-12 flex flex-col items-center justify-center space-y-4">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-muted-foreground">Carregando imagens...</p>
</div>
) : (
<div className="glass-effect rounded-xl p-12 text-center">
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{searchQuery ? "Nenhuma imagem encontrada" : "Nenhuma imagem gerada ainda"}
</p>
</div>
<>
{/* Images Grid */}
{filteredImages.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredImages.map((image) => (
<ImageCard
key={image.id}
image={image}
onDelete={handleDelete}
onDownload={handleDownload}
/>
))}
</div>
{/* Pagination */}
{!searchQuery && images.length >= perPage && (
<div className="flex items-center justify-center gap-4 mt-6">
<Pagination>
<PaginationContent>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="gap-1"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden md:inline">Anterior</span>
</Button>
</PaginationItem>
<PaginationItem>
<span className="text-sm text-muted-foreground px-4">
Página {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={images.length < perPage}
className="gap-1"
>
<span className="hidden md:inline">Próxima</span>
<ChevronRight className="h-4 w-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
) : (
<div className="glass-effect rounded-xl p-12 text-center">
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{searchQuery ? "Nenhuma imagem encontrada" : "Nenhuma imagem gerada ainda"}
</p>
</div>
)}
</>
)}
</TabsContent>
</Tabs>
@@ -202,31 +414,96 @@ export const ImageView = () => {
};
interface ImageCardProps {
image: GeneratedImage;
image: ImageRecord;
onDelete: (id: string) => void;
onDownload: (image: ImageRecord) => void;
}
const ImageCard = ({ image, onDelete }: ImageCardProps) => {
const ImageCard = ({ image, onDelete, onDownload }: ImageCardProps) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
// Calcula tempo relativo
const getRelativeTime = (timestamp: string) => {
const now = new Date();
const date = new Date(timestamp);
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Agora';
if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
if (hours < 24) return `${hours} ${hours === 1 ? 'hora' : 'horas'}`;
return `${days} ${days === 1 ? 'dia' : 'dias'}`;
};
const handleImageError = () => {
console.warn('Erro CORS ao carregar imagem:', image.image_url);
setImageError(true);
setImageLoading(false);
};
const handleImageLoad = () => {
console.log('Imagem carregada com sucesso:', image.image_url);
setImageLoading(false);
setImageError(false);
};
return (
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
<div className="relative aspect-square">
<img
src={image.url}
alt={image.prompt}
className="w-full h-full object-cover"
/>
{imageLoading && !imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-muted">
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
)}
{imageError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted text-muted-foreground p-4">
<Sparkles className="w-12 h-12 mb-2 opacity-50" />
<p className="text-sm text-center font-medium mb-1">Erro ao carregar</p>
<p className="text-xs text-center mb-2">A imagem foi gerada, mas não pode ser exibida aqui</p>
<div className="flex flex-col gap-2 w-full">
<a
href={image.image_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-center bg-primary text-primary-foreground px-3 py-2 rounded-md hover:bg-primary/90 transition-colors"
>
Abrir imagem em nova aba
</a>
<button
onClick={() => navigator.clipboard.writeText(image.image_url)}
className="text-xs text-center bg-secondary text-secondary-foreground px-3 py-1 rounded-md hover:bg-secondary/80 transition-colors"
>
Copiar URL
</button>
</div>
</div>
) : (
<img
src={image.image_url}
alt={image.description}
className="w-full h-full object-cover"
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
referrerPolicy="no-referrer"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/0 to-black/0 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<p className="text-sm text-white line-clamp-2">{image.prompt}</p>
<p className="text-sm text-white line-clamp-2">{image.description}</p>
<div className="flex items-center justify-between text-xs text-white/70">
<span>{image.size}</span>
<span>{image.timestamp}</span>
<span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
<span>{getRelativeTime(image.created_at)}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
className="flex-1 gap-1"
onClick={() => onDownload(image)}
>
<Download className="w-3 h-3" />
Baixar
+158
View File
@@ -0,0 +1,158 @@
/**
* Configuração de modelos de IA disponíveis
* Mapeia os nomes de exibição para os IDs usados pela API
*/
export interface ModelConfig {
name: string; // Nome exibido na interface
id: string; // ID enviado para a API
description?: string; // Descrição opcional do modelo
}
/**
* Interface para modelo retornado pela API do banco de dados
*/
export interface ModelIA {
id: number;
provider_id: number;
name: string;
model_identifier: string;
cost_input_per_million: string;
cost_output_per_million: string;
is_active: number;
created_at: string;
updated_at: string;
}
/**
* Lista de modelos disponíveis
* NOTA: Estes dados correspondem à tabela de modelos do banco de dados.
* Futuramente será integrado com API para buscar dinamicamente.
*/
export const AVAILABLE_MODELS: ModelConfig[] = [
{
name: 'GPT-4.1',
id: '1',
description: 'Modelo GPT-4.1',
},
{
name: 'GPT-4o',
id: '2',
description: 'Modelo GPT-4o',
},
{
name: 'GPT-5 Mini',
id: '3',
description: 'Modelo GPT-5 Mini',
},
{
name: 'Gemini 2.0 Flash',
id: '4',
description: 'Google Gemini 2.0 Flash',
},
{
name: 'Claude Sonnet 4.5',
id: '5',
description: 'Anthropic Claude Sonnet 4.5',
},
{
name: 'DeepSeek V3.2 Chat',
id: '6',
description: 'DeepSeek V3.2 Chat',
},
{
name: 'DeepSeek V3.2 Reasoner',
id: '7',
description: 'DeepSeek V3.2 Reasoner',
},
{
name: 'Gemini 2.5 Flash',
id: '8',
description: 'Google Gemini 2.5 Flash',
},
{
name: 'Gemini 2.5 Flash-Lite',
id: '9',
description: 'Google Gemini 2.5 Flash-Lite',
},
{
name: 'Claude Haiku 4.5',
id: '10',
description: 'Anthropic Claude Haiku 4.5',
},
{
name: 'GPT-4o Mini',
id: '11',
description: 'Modelo GPT-4o Mini',
},
];
/**
* Mapeia o nome do modelo para o ID da API
*
* @param modelName - Nome do modelo exibido na interface
* @returns ID do modelo para a API, ou o próprio nome se não encontrado
*/
export function getModelId(modelName: string): string {
const model = AVAILABLE_MODELS.find(m => m.name === modelName);
return model?.id || modelName;
}
/**
* Mapeia o ID da API para o nome de exibição
*
* @param modelId - ID do modelo na API
* @returns Nome do modelo para exibir, ou o próprio ID se não encontrado
*/
export function getModelName(modelId: string): string {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
return model?.name || modelId;
}
/**
* Obtém a configuração completa de um modelo pelo nome
*
* @param modelName - Nome do modelo
* @returns Configuração do modelo ou undefined
*/
export function getModelConfig(modelName: string): ModelConfig | undefined {
return AVAILABLE_MODELS.find(m => m.name === modelName);
}
/**
* Retorna apenas os nomes dos modelos para uso em dropdowns
*/
export function getModelNames(): string[] {
return AVAILABLE_MODELS.map(m => m.name);
}
/**
* Busca modelos de IA disponíveis no banco de dados
*
* @returns Promise com array de modelos ativos
*/
export async function fetchModelsFromDatabase(): Promise<ModelConfig[]> {
try {
// Importa dinamicamente para evitar circular dependency
const { apiService } = await import('@/services/api');
const response = await apiService.get<ModelIA[]>('/webhook/codex/get_models_ia');
console.log('Modelos carregados da API:', response.data);
// Filtra apenas modelos ativos e converte para ModelConfig
return response.data
.filter(model => model.is_active === 1)
.map((model: ModelIA) => ({
name: model.name,
id: model.id.toString(),
description: `${model.model_identifier}`,
}));
} catch (error) {
console.error('Erro ao buscar modelos da API:', error);
// Fallback para modelos hardcoded em caso de erro
console.warn('Usando modelos hardcoded como fallback');
return AVAILABLE_MODELS;
}
}
+72 -1
View File
@@ -31,6 +31,12 @@
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 222 47% 11%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 190 100% 45%;
@@ -84,6 +90,12 @@
--destructive: 0 84% 60%;
--destructive-foreground: 200 100% 95%;
--success: 142 71% 45%;
--success-foreground: 222 47% 5%;
--warning: 38 92% 50%;
--warning-foreground: 222 47% 5%;
--border: 222 30% 18%;
--input: 222 30% 18%;
--ring: 190 100% 50%;
@@ -137,7 +149,7 @@
@layer components {
.glass-effect {
@apply bg-card/40 backdrop-blur-xl border border-border/50;
@apply bg-card/80 backdrop-blur-xl border border-border/50;
}
.cyber-glow {
@@ -152,4 +164,63 @@
.gradient-text {
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent;
}
.metric-card {
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
}
.nav-item {
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full;
display: flex;
align-items: center;
text-decoration: none;
}
.nav-item svg,
.nav-item .w-5 {
flex-shrink: 0;
}
.nav-item span {
white-space: nowrap;
}
.nav-item-inactive {
@apply text-muted-foreground hover:text-foreground hover:bg-sidebar-accent;
}
.nav-item-active {
@apply text-primary bg-sidebar-accent font-medium;
}
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-connected {
@apply bg-success;
box-shadow: 0 0 8px hsl(var(--success) / 0.6);
animation: pulse 2s infinite;
}
.status-error {
@apply bg-destructive;
box-shadow: 0 0 8px hsl(var(--destructive) / 0.6);
animation: pulse 2s infinite;
}
.status-warning {
@apply bg-warning;
box-shadow: 0 0 8px hsl(var(--warning) / 0.6);
animation: pulse 2s infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Routes, Route } from "react-router-dom";
import { MainLayout } from "@/modules/intelligence-ia/components/layout/MainLayout";
import Index from "./pages/Index";
import Integrations from "./pages/Integrations";
import Financas from "./pages/Financas";
import MeuPerfil from "./pages/MeuPerfil";
import NotFound from "./pages/NotFound";
// App do módulo Intelligence IA - funciona como subpath
// Não precisa de BrowserRouter, QueryClientProvider, TooltipProvider, Toaster ou Sonner
// pois já são fornecidos pelo App principal
const IntelligenceIAApp = () => {
return (
<MainLayout>
<Routes>
<Route index element={<Index />} />
<Route path="integracoes" element={<Integrations />} />
<Route path="financas" element={<Financas />} />
<Route path="meu-perfil" element={<MeuPerfil />} />
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
);
};
export default IntelligenceIAApp;
Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

@@ -0,0 +1,28 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };
@@ -0,0 +1,44 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: ReactNode;
status?: "success" | "warning" | "error";
className?: string;
}
export function MetricCard({
title,
value,
subtitle,
icon,
status,
className,
}: MetricCardProps) {
return (
<div className={cn("metric-card animate-fade-in", className)}>
<div className="flex items-start justify-between mb-3">
<div className="p-2.5 rounded-lg bg-primary/10 text-primary">
{icon}
</div>
{status && (
<span
className={cn("status-dot", {
"status-connected": status === "success",
"status-warning": status === "warning",
"status-error": status === "error",
})}
/>
)}
</div>
<p className="text-muted-foreground text-sm mb-1">{title}</p>
<p className="text-2xl font-semibold text-foreground">{value}</p>
{subtitle && (
<p className="text-muted-foreground text-xs mt-1">{subtitle}</p>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
interface StatusBadgeProps {
status: "connected" | "error" | "disconnected" | "pending";
label?: string;
}
const statusConfig = {
connected: {
label: "Conectado",
className: "bg-success/10 text-success border-success/20",
},
error: {
label: "Erro",
className: "bg-destructive/10 text-destructive border-destructive/20",
},
disconnected: {
label: "Desconectado",
className: "bg-muted text-muted-foreground border-border",
},
pending: {
label: "Pendente",
className: "bg-warning/10 text-warning border-warning/20",
},
};
export function StatusBadge({ status, label }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
config.className
)}
>
<span
className={cn("w-1.5 h-1.5 rounded-full", {
"bg-success": status === "connected",
"bg-destructive": status === "error",
"bg-muted-foreground": status === "disconnected",
"bg-warning": status === "pending",
})}
/>
{label || config.label}
</span>
);
}
@@ -0,0 +1,291 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import asanaLogo from "@/modules/intelligence-ia/assets/asana-logo.png";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
interface AsanaCardProps {
apiKey?: string;
workspaces?: Workspace[];
users?: User[];
selectedWorkspaceId?: string;
selectedUserId?: string;
loadingWorkspaces?: boolean;
loadingUsers?: boolean;
loadingIntegration?: boolean;
hasIntegration?: boolean;
saving?: boolean;
onSave?: (apiKey: string, workspaceId: string, userId: string) => void | Promise<void>;
onConfirmApiKey?: (apiKey: string) => void | Promise<void>;
onWorkspaceChange?: (workspaceId: string) => void | Promise<void>;
}
export function AsanaCard({
apiKey = "",
workspaces = [],
users = [],
selectedWorkspaceId = "",
selectedUserId = "",
loadingWorkspaces = false,
loadingUsers = false,
loadingIntegration = false,
hasIntegration = false,
saving = false,
onSave,
onConfirmApiKey,
onWorkspaceChange,
}: AsanaCardProps) {
const [showKey, setShowKey] = useState(false);
const [key, setKey] = useState(apiKey);
const [selectedWorkspace, setSelectedWorkspace] = useState(selectedWorkspaceId);
const [selectedUser, setSelectedUser] = useState(selectedUserId);
const [isApiKeyConfirmed, setIsApiKeyConfirmed] = useState(!!apiKey);
// Atualiza os valores quando as props mudam (carregamento inicial)
useEffect(() => {
if (apiKey) {
setKey(apiKey);
setIsApiKeyConfirmed(true);
}
}, [apiKey]);
useEffect(() => {
if (selectedWorkspaceId) {
setSelectedWorkspace(selectedWorkspaceId);
}
}, [selectedWorkspaceId]);
useEffect(() => {
if (selectedUserId) {
setSelectedUser(selectedUserId);
}
}, [selectedUserId]);
const handleKeyChange = (value: string) => {
setKey(value);
setSelectedWorkspace("");
setSelectedUser("");
setIsApiKeyConfirmed(false);
};
const handleConfirmApiKey = async () => {
if (key.length >= 5) {
setIsApiKeyConfirmed(true);
// Chama a função assíncrona para buscar workspaces
await onConfirmApiKey?.(key);
}
};
const handleWorkspaceChange = async (value: string) => {
setSelectedWorkspace(value);
setSelectedUser("");
// Chama a função assíncrona para buscar usuários
await onWorkspaceChange?.(value);
};
const handleSave = async () => {
await onSave?.(key, selectedWorkspace, selectedUser);
};
return (
<div className="metric-card space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={asanaLogo} alt="Asana" className="w-6 h-6" />
</div>
<div>
<h3 className="text-foreground font-medium">Asana</h3>
<p className="text-muted-foreground text-sm">Gerencie tarefas e projetos</p>
</div>
</div>
<div className="space-y-3">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
API Key / Token
</label>
<div className="relative">
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</div>
) : (
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showKey ? "text" : "password"}
value={key}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="Insira sua chave de API"
className="pr-10 bg-card border-border"
disabled={loadingWorkspaces}
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
disabled={loadingWorkspaces}
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<Button
size="sm"
variant="outline"
onClick={handleConfirmApiKey}
disabled={key.length < 5 || isApiKeyConfirmed || loadingWorkspaces}
className="shrink-0"
>
{loadingWorkspaces ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Carregando...
</>
) : isApiKeyConfirmed ? (
"Confirmado"
) : (
"Confirmar"
)}
</Button>
</div>
)}
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Workspace
</label>
{loadingIntegration ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando integração...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedWorkspace}
onValueChange={handleWorkspaceChange}
disabled={loadingWorkspaces || loadingIntegration}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
loadingWorkspaces
? "Carregando workspaces..."
: workspaces.length === 0
? "Insira o token e clique em Confirmar"
: "Selecione um workspace"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{workspaces.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingWorkspaces && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Usuário
</label>
{loadingUsers ? (
<div className="relative">
<Skeleton className="h-10 w-full" />
<div className="absolute inset-0 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando usuários...</span>
</div>
</div>
) : (
<div className="relative">
<Select
value={selectedUser}
onValueChange={setSelectedUser}
disabled={loadingUsers || !selectedWorkspace}
>
<SelectTrigger className="bg-card border-border">
<SelectValue
placeholder={
!selectedWorkspace
? "Selecione um workspace primeiro"
: users.length === 0
? "Nenhum usuário encontrado"
: "Selecione um usuário"
}
/>
</SelectTrigger>
<SelectContent className="bg-card border-border">
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
onClick={handleSave}
disabled={!selectedWorkspace || !selectedUser || saving}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{hasIntegration ? "Salvando..." : "Criando..."}
</>
) : hasIntegration ? (
"Salvar"
) : (
"Criar Integração"
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import googleCalendarLogo from "@/modules/intelligence-ia/assets/google-calendar-logo.png";
export function GoogleCalendarCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<img src={googleCalendarLogo} alt="Google Calendar" className="w-6 h-6 grayscale" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Calendar</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Sincronize reuniões e eventos</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Nome da Agenda
</label>
<Input
type="text"
disabled
placeholder="Ex: Reuniões de Trabalho"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { FileSpreadsheet } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function GoogleSheetsCard() {
return (
<div className="metric-card space-y-4 opacity-60">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center">
<FileSpreadsheet className="w-6 h-6 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="text-foreground font-medium">Google Sheets</h3>
<span className="text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full">
Em breve
</span>
</div>
<p className="text-muted-foreground text-sm">Gerencie planilhas e dados</p>
</div>
</div>
<div className="space-y-3 pointer-events-none">
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client ID
</label>
<Input
type="text"
disabled
placeholder="Insira o Client ID"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Client Secret
</label>
<Input
type="password"
disabled
placeholder="Insira o Client Secret"
className="bg-card border-border"
/>
</div>
<div>
<label className="text-sm text-muted-foreground mb-1.5 block">
Link da Planilha
</label>
<Input
type="text"
disabled
placeholder="Cole o link da planilha aqui"
className="bg-card border-border"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
Salvar
</Button>
</div>
</div>
);
}
@@ -0,0 +1,127 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Plug,
Bot,
ChevronLeft,
Menu,
X,
DollarSign,
UserCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ title: "Home", path: "", icon: LayoutDashboard },
{ title: "Finanças", path: "financas", icon: DollarSign },
{ title: "Integrações", path: "integracoes", icon: Plug },
{ title: "Meu Perfil", path: "meu-perfil", icon: UserCircle },
];
export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const SidebarContent = () => (
<>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center cyber-glow">
<Bot className="w-5 h-5 text-primary" />
</div>
{!collapsed && (
<div className="animate-fade-in">
<h1 className="text-sidebar-foreground font-semibold text-lg">AI Agent</h1>
<p className="text-muted-foreground text-xs">Painel Admin</p>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === ""} // Apenas a Home precisa de match exato
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
"nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full",
isActive
? "text-primary bg-sidebar-accent font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
)
}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</nav>
{/* Collapse button - desktop only */}
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
<button
onClick={() => setCollapsed(!collapsed)}
className="nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full justify-center lg:justify-start text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
>
<ChevronLeft
className={cn(
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
collapsed && "rotate-180"
)}
/>
{!collapsed && <span>Recolher</span>}
</button>
</div>
</>
);
return (
<>
{/* Mobile menu button */}
<button
onClick={() => setMobileOpen(true)}
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border shadow-sm"
>
<Menu className="w-5 h-5" />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={cn(
"lg:hidden fixed left-0 top-0 h-full w-64 bg-sidebar z-50 flex flex-col border-r border-sidebar-border transition-transform duration-300",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute top-4 right-4 p-2 rounded-lg hover:bg-sidebar-accent"
>
<X className="w-5 h-5" />
</button>
<SidebarContent />
</aside>
{/* Desktop sidebar */}
<aside
className={cn(
"hidden lg:flex flex-col h-screen bg-sidebar border-r border-sidebar-border transition-all duration-300 sticky top-0",
collapsed ? "w-[72px]" : "w-64"
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -0,0 +1,19 @@
import { ReactNode } from "react";
import { AppSidebar } from "./AppSidebar";
interface MainLayoutProps {
children: ReactNode;
}
export function MainLayout({ children }: MainLayoutProps) {
return (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">
{children}
</div>
</main>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Bot, Sparkles, Zap, Brain, MessageCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { GlobalFunctions } from "@/GlobalFunctions";
export default function Dashboard() {
const today = new Date();
const formattedDate = format(today, "EEEE, d 'de' MMMM 'de' yyyy", { locale: ptBR });
const hour = today.getHours();
const getGreeting = () => {
if (hour < 12) return "Bom dia";
if (hour < 18) return "Boa tarde";
return "Boa noite";
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] text-center px-4">
{/* Floating particles background effect */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/30 rounded-full animate-pulse" />
<div className="absolute top-1/3 right-1/3 w-1 h-1 bg-primary/40 rounded-full animate-pulse delay-300" />
<div className="absolute bottom-1/3 left-1/3 w-1.5 h-1.5 bg-primary/20 rounded-full animate-pulse delay-500" />
<div className="absolute top-1/2 right-1/4 w-1 h-1 bg-primary/30 rounded-full animate-pulse delay-700" />
</div>
{/* AI Agent Avatar */}
<div className="relative mb-8 animate-fade-in">
{/* Outer glow ring */}
<div className="absolute inset-0 w-32 h-32 rounded-full bg-gradient-to-r from-primary/20 via-primary/10 to-primary/20 blur-xl animate-pulse" />
{/* Middle ring */}
<div className="relative w-32 h-32 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 p-1 cyber-glow">
<div className="w-full h-full rounded-full bg-card border border-primary/30 flex items-center justify-center relative overflow-hidden">
{/* Inner gradient effect */}
<div className="absolute inset-0 bg-gradient-to-t from-primary/10 to-transparent" />
{/* Bot icon */}
<Bot className="w-14 h-14 text-primary relative z-10" />
{/* Scanning line effect */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/20 to-transparent h-8 animate-[slide-scan_2s_ease-in-out_infinite]" />
</div>
</div>
{/* Orbiting icons */}
<div className="absolute -top-2 -right-2 w-10 h-10 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-bounce">
<Sparkles className="w-5 h-5 text-yellow-500" />
</div>
<div className="absolute -bottom-1 -left-1 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse">
<Zap className="w-4 h-4 text-primary" />
</div>
<div className="absolute top-1/2 -right-4 w-8 h-8 rounded-full bg-card border border-border flex items-center justify-center shadow-lg animate-pulse delay-300">
<Brain className="w-4 h-4 text-primary" />
</div>
</div>
{/* Greeting */}
<div className="space-y-3 animate-fade-in" style={{ animationDelay: "0.2s" }}>
<h1 className="text-4xl md:text-5xl font-bold text-foreground">
{getGreeting()}! 👋
</h1>
<p className="text-xl text-muted-foreground">
Seu agente de IA está pronto para ajudar
</p>
</div>
{/* Date card */}
<div
className="mt-8 px-6 py-3 rounded-full bg-card/50 border border-border backdrop-blur-sm animate-fade-in"
style={{ animationDelay: "0.4s" }}
>
<p className="text-muted-foreground capitalize">
📅 {formattedDate}
</p>
</div>
{/* Status indicator */}
<div
className="mt-6 flex items-center gap-2 text-sm animate-fade-in"
style={{ animationDelay: "0.6s" }}
>
<span className="relative flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
<span className="text-muted-foreground">Sistema operacional</span>
</div>
{/* WhatsApp Button */}
<Button
asChild
size="lg"
variant="outline"
className="mt-8 gap-2 border-[#25D366] text-[#25D366] hover:bg-[#25D366] hover:text-white hover:border-[#25D366] animate-fade-in"
style={{ animationDelay: "0.8s" }}
>
<a href="https://wa.me/5511914382960" target="_blank" rel="noopener noreferrer">
<MessageCircle className="w-5 h-5" />
Conversar com o Agente
</a>
</Button>
</div>
);
}
@@ -0,0 +1,777 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
DollarSign,
TrendingUp,
TrendingDown,
Building2,
User,
Receipt,
Filter,
Calendar,
Search,
CalendarIcon,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { Badge } from "@/components/ui/badge";
import { personalAgent, FinancialIndicators, ExpenseItem, ExpensesResponse, ExpensesFilters, ExpenseCategory } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { toast } from "@/hooks/use-toast";
// Mapeamento de categoria_id para cores (cores padrão caso não tenha categoria)
const categoryColorMap: Record<number, string> = {
1: "bg-orange-500/10 text-orange-600 border-orange-500/20", // Alimentação
2: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20", // Combustível
3: "bg-purple-500/10 text-purple-600 border-purple-500/20", // Hospedagem
4: "bg-blue-500/10 text-blue-600 border-blue-500/20", // Estacionamento
5: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20", // Pedágio
6: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20", // Transporte
};
// Função auxiliar para obter informações da categoria
const getCategoryInfo = (categoriaId: number | undefined | null, categories: ExpenseCategory[]) => {
// Se não tem categoria_id válido, retorna padrão
if (categoriaId === undefined || categoriaId === null || categoriaId === 0) {
return {
label: "Outros",
color: "bg-gray-500/10 text-gray-600 border-gray-500/20",
};
}
// Converte para número caso venha como string
const id = typeof categoriaId === 'string' ? parseInt(categoriaId, 10) : categoriaId;
// Busca a categoria na lista carregada
const category = categories.find(cat => cat.id === id);
// Se encontrou a categoria, usa o nome dela
if (category) {
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: category.nome,
color: color,
};
}
// Se não encontrou mas tem ID válido, usa o ID com cor padrão
const color = categoryColorMap[id] || "bg-gray-500/10 text-gray-600 border-gray-500/20";
return {
label: `Categoria ${id}`,
color: color,
};
};
function formatCurrency(value: number) {
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
}
function formatDateTime(dateTimeString: string) {
// Formata "2026-01-14 10:38:57" para Date
const date = new Date(dateTimeString.replace(" ", "T"));
return {
date: date.toLocaleDateString("pt-BR"),
time: date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }),
};
}
export default function Financas() {
const [searchTerm, setSearchTerm] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all"); // Agora será o ID da categoria ou "all"
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
// Estados para os indicadores financeiros da API
const [loadingIndicators, setLoadingIndicators] = useState(true);
const [indicators, setIndicators] = useState<FinancialIndicators | null>(null);
// Estados para as despesas da API
const [loadingExpenses, setLoadingExpenses] = useState(true);
const [expensesData, setExpensesData] = useState<ExpensesResponse | null>(null);
const [userEmail, setUserEmail] = useState<string>("");
// Estados para as categorias da API
const [loadingCategories, setLoadingCategories] = useState(true);
const [categories, setCategories] = useState<ExpenseCategory[]>([]);
// Carrega os indicadores financeiros da API
useEffect(() => {
const loadFinancialIndicators = async () => {
try {
setLoadingIndicators(true);
// Obtém o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
console.error("Financas: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoadingIndicators(false);
return;
}
// Busca os indicadores financeiros da API
const response = await personalAgent.getFinancialIndicators(userEmail);
if (response.success) {
setIndicators(response);
} else {
throw new Error(response.message || "Erro ao carregar indicadores financeiros");
}
} catch (error: unknown) {
console.error("Erro ao carregar indicadores financeiros:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar indicadores financeiros.",
variant: "destructive",
});
} finally {
setLoadingIndicators(false);
}
};
loadFinancialIndicators();
}, []);
// Função auxiliar para obter o email do usuário
const getUserEmail = async (): Promise<string | null> => {
let email = GlobalFunctions.getUsuarioLogado().email;
if (!email) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
email = transferEmail as string;
}
}
if (!email) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
email = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
return email || null;
};
// Carrega as despesas da API
const loadExpenses = useCallback(async (page: number = 1) => {
const emailToUse = userEmail || await getUserEmail();
if (!emailToUse) {
setLoadingExpenses(false);
return;
}
if (!userEmail && emailToUse) {
setUserEmail(emailToUse);
}
try {
setLoadingExpenses(true);
// Prepara filtros para a API
const filters: ExpensesFilters = {
page,
per_page: itemsPerPage,
};
if (searchTerm.trim()) {
filters.descricao = searchTerm.trim();
}
if (categoryFilter !== "all") {
// Usa o ID da categoria diretamente
const categoriaId = parseInt(categoryFilter, 10);
if (!isNaN(categoriaId)) {
filters.categoria_id = categoriaId;
}
}
if (startDate) {
filters.data_inicial = format(startDate, "yyyy-MM-dd");
}
if (endDate) {
filters.data_final = format(endDate, "yyyy-MM-dd");
}
const response = await personalAgent.getExpenses(emailToUse, filters);
if (response.success) {
// Garante que a resposta tenha estrutura válida mesmo quando não há dados
const safeResponse: ExpensesResponse = {
success: response.success,
total_registros: response.total_registros || 0,
total_paginas: response.total_paginas || 0,
per_page: response.per_page || itemsPerPage,
pagina_atual: response.pagina_atual || page,
data: Array.isArray(response.data)
? response.data.filter((item) => item && item.id && Object.keys(item).length > 0)
: [],
};
setExpensesData(safeResponse);
} else {
throw new Error("Erro ao carregar despesas");
}
} catch (error: unknown) {
console.error("Erro ao carregar despesas:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar despesas.",
variant: "destructive",
});
} finally {
setLoadingExpenses(false);
}
}, [userEmail, searchTerm, categoryFilter, startDate, endDate, itemsPerPage]);
// Carrega o email do usuário e as categorias na montagem do componente
useEffect(() => {
const initData = async () => {
// Carrega email do usuário
const email = await getUserEmail();
if (email) {
setUserEmail(email);
}
// Carrega categorias
try {
setLoadingCategories(true);
const categoriesData = await personalAgent.getCategories();
setCategories(categoriesData);
} catch (error) {
console.error("Erro ao carregar categorias:", error);
toast({
title: "Aviso",
description: "Não foi possível carregar as categorias. Usando categorias padrão.",
variant: "default",
});
} finally {
setLoadingCategories(false);
}
};
initData();
}, []);
// Debounce para busca de descrição
useEffect(() => {
const timeoutId = setTimeout(() => {
if (userEmail) {
setCurrentPage(1); // Reseta para página 1 ao buscar
loadExpenses(1);
}
}, 500); // Aguarda 500ms após parar de digitar
return () => clearTimeout(timeoutId);
}, [searchTerm, userEmail, loadExpenses]);
// Carrega despesas quando os filtros ou página mudam (exceto searchTerm que tem debounce)
useEffect(() => {
if (userEmail) {
loadExpenses(currentPage);
}
}, [currentPage, typeFilter, categoryFilter, startDate, endDate, userEmail, loadExpenses]);
// Calculate totals - usa dados da API se disponível
const totals = useMemo(() => {
if (indicators && indicators.success) {
return {
total: parseFloat(indicators.total_despesas) || 0,
corporate: parseFloat(indicators.total_corporativo) || 0,
personal: parseFloat(indicators.total_pessoal) || 0,
};
}
return { total: 0, personal: 0, corporate: 0 };
}, [indicators]);
// Filtra despesas por tipo (se necessário, já que a API pode não filtrar por tipo)
const filteredExpenses = useMemo(() => {
if (!expensesData || !expensesData.data || !Array.isArray(expensesData.data)) {
return [];
}
// Filtra objetos vazios ou inválidos
let expenses = expensesData.data.filter((expense) => {
// Verifica se o expense é válido e tem propriedades necessárias
// categoria_id pode ser opcional, então não validamos ele aqui
return expense &&
typeof expense === 'object' &&
expense.id &&
expense.descricao &&
expense.data_hora &&
expense.valor;
});
// Filtra por tipo se necessário (a API pode não ter esse filtro)
if (typeFilter !== "all") {
expenses = expenses.filter((expense) => expense.tipo === typeFilter);
}
return expenses;
}, [expensesData, typeFilter]);
const clearFilters = () => {
setSearchTerm("");
setTypeFilter("all");
setCategoryFilter("all");
setStartDate(undefined);
setEndDate(undefined);
setCurrentPage(1);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div>
<h1 className="text-2xl font-bold gradient-text">Finanças</h1>
<p className="text-muted-foreground mt-1">
Análise de despesas pessoais e corporativas identificadas pelo agente
</p>
</div>
{/* Indicators */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total de Despesas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.total)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-primary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Corporativas</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.corporate)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-secondary/10 flex items-center justify-center">
<Building2 className="w-6 h-6 text-secondary" />
</div>
</div>
</CardContent>
</Card>
<Card className="metric-card">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Despesas Pessoais</p>
{loadingIndicators ? (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando...</span>
</div>
) : (
<>
<p className="text-2xl font-bold mt-1">{formatCurrency(totals.personal)}</p>
</>
)}
</div>
<div className="w-12 h-12 rounded-xl bg-warning/10 flex items-center justify-center">
<User className="w-6 h-6 text-warning" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="w-4 h-4" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
{/* First row: Search + Date filters */}
<div className="flex flex-col md:flex-row gap-3">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Buscar por descrição..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-9"
/>
</div>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "dd/MM/yyyy") : "Data inicial"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={startDate}
onSelect={(date) => {
setStartDate(date);
setCurrentPage(1);
}}
locale={ptBR}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-[160px] justify-start text-left font-normal",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "dd/MM/yyyy") : "Data final"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={endDate}
onSelect={(date) => {
setEndDate(date);
setCurrentPage(1);
}}
locale={ptBR}
disabled={(date) => startDate ? date < startDate : false}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
</PopoverContent>
</Popover>
</div>
{/* Second row: Type + Category + Clear */}
<div className="flex flex-col md:flex-row gap-3">
<Select
value={typeFilter}
onValueChange={(value) => {
setTypeFilter(value);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os tipos</SelectItem>
<SelectItem value="pessoal">Pessoal</SelectItem>
<SelectItem value="corporativo">Corporativo</SelectItem>
</SelectContent>
</Select>
<Select
value={categoryFilter}
onValueChange={(value) => {
setCategoryFilter(value);
setCurrentPage(1);
}}
disabled={loadingCategories}
>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder={loadingCategories ? "Carregando..." : "Categoria"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas categorias</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={clearFilters}>
Limpar filtros
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Receipt className="w-4 h-4" />
Registro de Despesas
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
<div className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
Data/Hora
</div>
</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-[120px]">Tipo</TableHead>
<TableHead className="w-[130px]">Categoria</TableHead>
<TableHead className="w-[120px] text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingExpenses ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Carregando despesas...</span>
</div>
</TableCell>
</TableRow>
) : filteredExpenses.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
{expensesData && expensesData.total_registros === 0
? "Nenhuma despesa encontrada."
: "Nenhuma despesa encontrada com os filtros aplicados."}
</TableCell>
</TableRow>
) : (
filteredExpenses.map((expense) => {
// Validação adicional para garantir que o expense é válido
if (!expense || !expense.id || !expense.data_hora || !expense.descricao || !expense.valor) {
return null;
}
try {
const { date, time } = formatDateTime(expense.data_hora);
// Usa categoria_id diretamente (pode ser number, undefined ou null)
const categoriaId = expense.categoria_id;
const categoryInfo = getCategoryInfo(categoriaId, categories);
const valor = parseFloat(expense.valor) || 0;
return (
<TableRow key={expense.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{date}</span>
<span className="text-xs text-muted-foreground">{time}</span>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{expense.descricao || "Sem descrição"}</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
expense.tipo === "corporativo"
? "bg-secondary/10 text-secondary border-secondary/20"
: "bg-warning/10 text-warning border-warning/20"
}
>
{expense.tipo === "corporativo" ? (
<Building2 className="w-3 h-3 mr-1" />
) : (
<User className="w-3 h-3 mr-1" />
)}
{expense.tipo === "corporativo" ? "Corp." : "Pessoal"}
</Badge>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge
variant="outline"
className={categoryInfo.color}
>
{categoryInfo.label}
</Badge>
) : (
<Badge
variant="outline"
className="bg-gray-500/10 text-gray-600 border-gray-500/20"
>
Sem categoria
</Badge>
)}
</TableCell>
<TableCell className="text-right font-semibold">
{formatCurrency(valor)}
</TableCell>
</TableRow>
);
} catch (error) {
console.error("Erro ao renderizar despesa:", error, expense);
return null;
}
}).filter(Boolean) // Remove nulls do array
)}
</TableBody>
</Table>
{/* Pagination */}
{expensesData && expensesData.total_paginas > 0 && expensesData.total_paginas > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Mostrando {(currentPage - 1) * itemsPerPage + 1} a{" "}
{Math.min(currentPage * itemsPerPage, expensesData.total_registros)} de{" "}
{expensesData.total_registros} registros
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
{Array.from({ length: expensesData.total_paginas }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
onClick={() => setCurrentPage(page)}
isActive={currentPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => {
if (currentPage < expensesData.total_paginas) {
setCurrentPage(currentPage + 1);
}
}}
className={
currentPage === expensesData.total_paginas
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,7 @@
import Dashboard from "./Dashboard";
const Index = () => {
return <Dashboard />;
};
export default Index;
@@ -0,0 +1,310 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { GoogleCalendarCard } from "@/modules/intelligence-ia/components/integrations/GoogleCalendarCard";
import { GoogleSheetsCard } from "@/modules/intelligence-ia/components/integrations/GoogleSheetsCard";
import { AsanaCard } from "@/modules/intelligence-ia/components/integrations/AsanaCard";
import { asanaService } from "@/services/asana";
import { personalAgent } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
interface Workspace {
id: string;
name: string;
}
interface User {
id: string;
name: string;
}
export default function Integrations() {
const [asanaWorkspaces, setAsanaWorkspaces] = useState<Workspace[]>([]);
const [asanaUsers, setAsanaUsers] = useState<User[]>([]);
const [loadingAsanaWorkspaces, setLoadingAsanaWorkspaces] = useState(false);
const [loadingAsanaUsers, setLoadingAsanaUsers] = useState(false);
const [asanaToken, setAsanaToken] = useState<string>("");
const [asanaApiKey, setAsanaApiKey] = useState<string>("");
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>("");
const [selectedUserId, setSelectedUserId] = useState<string>("");
const [loadingIntegration, setLoadingIntegration] = useState(true);
const [hasIntegration, setHasIntegration] = useState(false);
const [integrationId, setIntegrationId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [saving, setSaving] = useState(false);
const handleAsanaSave = async (apiKey: string, workspaceId: string, usuarioAsanaId: string) => {
if (!apiKey || !workspaceId || !usuarioAsanaId) {
toast.error("Preencha todos os campos obrigatórios");
return;
}
// Encontra o nome do workspace e do usuário selecionado
const workspace = asanaWorkspaces.find(w => w.id === workspaceId);
const user = asanaUsers.find(u => u.id === usuarioAsanaId);
if (!workspace || !user) {
toast.error("Workspace ou usuário não encontrado");
return;
}
try {
setSaving(true);
if (hasIntegration && integrationId) {
// Atualiza integração existente
await asanaService.updateIntegration(integrationId, {
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
toast.success("Integração do Asana atualizada com sucesso!");
} else {
// Cria nova integração
if (!userId) {
toast.error("ID do usuário não encontrado");
return;
}
const response = await asanaService.createIntegration({
user_id: userId,
api_key: apiKey,
workspace_gid: workspaceId,
workspace_nome: workspace.name,
usuario_asana_gid: usuarioAsanaId,
usuario_asana_nome: user.name,
});
if (response.success && response.integracao_id) {
setIntegrationId(response.integracao_id);
setHasIntegration(true);
}
toast.success("Integração do Asana criada com sucesso!");
}
} catch (error: unknown) {
console.error("Erro ao salvar integração do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao salvar integração do Asana";
toast.error(errorMessage);
} finally {
setSaving(false);
}
};
const handleAsanaApiKeyConfirm = async (apiKey: string) => {
if (!apiKey || apiKey.trim().length === 0) {
toast.error("Insira um token válido do Asana");
return;
}
try {
setLoadingAsanaWorkspaces(true);
// Busca workspaces da API do Asana
const workspaces = await asanaService.getWorkspaces(apiKey);
if (workspaces && workspaces.length > 0) {
setAsanaWorkspaces(workspaces);
setAsanaUsers([]);
setAsanaToken(apiKey); // Armazena o token para usar na busca de usuários
toast.success(`${workspaces.length} workspace(s) encontrado(s)`);
} else {
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken("");
toast.warning("Nenhum workspace encontrado para este token");
}
} catch (error: unknown) {
console.error("Erro ao buscar workspaces do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar workspaces do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaWorkspaces([]);
setAsanaUsers([]);
setAsanaToken(""); // Limpa o token em caso de erro
} finally {
setLoadingAsanaWorkspaces(false);
}
};
const handleAsanaWorkspaceChange = async (workspaceId: string) => {
setSelectedWorkspaceId(workspaceId);
if (!workspaceId || !asanaToken) {
setAsanaUsers([]);
setSelectedUserId("");
return;
}
try {
setLoadingAsanaUsers(true);
// Busca usuários do workspace da API do Asana
const users = await asanaService.getUsers(asanaToken, workspaceId);
if (users && users.length > 0) {
setAsanaUsers(users);
// Não mostra toast ao selecionar workspace (só quando confirmar token)
} else {
setAsanaUsers([]);
setSelectedUserId("");
}
} catch (error: unknown) {
console.error("Erro ao buscar usuários do Asana:", error);
const errorMessage = error && typeof error === 'object' && 'message' in error
? (error as { message: string }).message
: "Erro ao buscar usuários do Asana. Verifique se o token está correto.";
toast.error(errorMessage);
setAsanaUsers([]);
setSelectedUserId("");
} finally {
setLoadingAsanaUsers(false);
}
};
// Carrega a integração do Asana ao montar o componente
useEffect(() => {
const loadAsanaIntegration = async () => {
try {
setLoadingIntegration(true);
// Obtém o ID do usuário do perfil
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("Erro ao fazer refresh do token:", error);
}
}
if (!userEmail) {
setLoadingIntegration(false);
return;
}
// Busca o perfil do usuário para obter o ID
const userProfile = await personalAgent.getUserProfile(userEmail);
if (!userProfile.success || !userProfile.id) {
setLoadingIntegration(false);
return;
}
// Armazena o ID do usuário para usar ao criar integração
setUserId(userProfile.id);
// Busca a integração do Asana
const integration = await asanaService.getIntegration(userProfile.id);
if (integration && integration.success) {
// Marca que há integração existente
setHasIntegration(true);
// Armazena o ID da integração (pode vir como integracao_id ou id)
if (integration.integracao_id) {
setIntegrationId(integration.integracao_id);
} else if (integration.id) {
setIntegrationId(integration.id);
}
// Preenche os dados da integração
if (integration.api_key) {
setAsanaApiKey(integration.api_key);
setAsanaToken(integration.api_key);
// Busca workspaces com o token
try {
const workspaces = await asanaService.getWorkspaces(integration.api_key);
setAsanaWorkspaces(workspaces);
// Se tem workspace_gid, seleciona e busca usuários
if (integration.workspace_gid) {
setSelectedWorkspaceId(integration.workspace_gid);
// Busca usuários do workspace
const users = await asanaService.getUsers(integration.api_key, integration.workspace_gid);
setAsanaUsers(users);
// Se tem usuario_asana_gid, seleciona
if (integration.usuario_asana_gid) {
setSelectedUserId(integration.usuario_asana_gid);
}
}
} catch (error) {
console.error("Erro ao carregar workspaces/usuários:", error);
// Continua mesmo se não conseguir carregar workspaces
}
}
} else {
// Não há integração, marca como false
setHasIntegration(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar integração do Asana:", error);
// Não mostra erro para o usuário, apenas deixa os campos vazios
} finally {
setLoadingIntegration(false);
}
};
loadAsanaIntegration();
}, []);
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold text-foreground mb-1">
Integrações
</h1>
<p className="text-muted-foreground">
Configure as conexões do seu agente com serviços externos
</p>
</div>
{/* Integrations Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<GoogleCalendarCard />
<GoogleSheetsCard />
<AsanaCard
apiKey={asanaApiKey}
workspaces={asanaWorkspaces}
users={asanaUsers}
selectedWorkspaceId={selectedWorkspaceId}
selectedUserId={selectedUserId}
onSave={handleAsanaSave}
onConfirmApiKey={handleAsanaApiKeyConfirm}
onWorkspaceChange={handleAsanaWorkspaceChange}
loadingWorkspaces={loadingAsanaWorkspaces}
loadingUsers={loadingAsanaUsers}
loadingIntegration={loadingIntegration}
hasIntegration={hasIntegration}
saving={saving}
/>
</div>
</div>
);
}
@@ -0,0 +1,500 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { User, Mail, Phone, Save, Bell, Loader2, Plus } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { personalAgent, UserProfile } from "@/services/personalAgent";
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/\D/g, "");
if (numbers.length <= 2) {
return numbers;
} else if (numbers.length <= 7) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2)}`;
} else if (numbers.length <= 11) {
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7)}`;
}
return `(${numbers.slice(0, 2)}) ${numbers.slice(2, 7)}-${numbers.slice(7, 11)}`;
};
const MeuPerfil = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [userExists, setUserExists] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [userId, setUserId] = useState<string>("");
const [nomeCompleto, setNomeCompleto] = useState("");
const [email, setEmail] = useState("");
const [whatsapp, setWhatsapp] = useState("");
const [receberLembretes, setReceberLembretes] = useState(true);
// Estados para o modal de criação
const [createNome, setCreateNome] = useState("");
const [createWhatsapp, setCreateWhatsapp] = useState("");
const [createFollowup, setCreateFollowup] = useState(true);
const [creating, setCreating] = useState(false);
// Verifica se o usuário está logado e carrega os dados
useEffect(() => {
const checkAuthAndLoad = async () => {
// Verifica se há token no sessionStorage primeiro
const jsonUsuario = sessionStorage.getItem('usuarioLogado');
if (!jsonUsuario) {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
// Tenta obter o email do usuário logado
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta fazer refresh do token
if (!userEmail) {
try {
await GlobalFunctions.getToken();
// Aguarda um pouco para o refresh ser processado (se necessário)
await new Promise(resolve => setTimeout(resolve, 1000));
// Verifica novamente após refresh
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao obter token:", error);
}
}
// Se ainda não tem email após tentar refresh, verifica se o token existe
// Se o token existe mas não tem email, pode ser problema na decodificação
if (!userEmail) {
const usuarioData = GlobalFunctions.getUsuarioLogado();
// Se não tem email mas tem token no sessionStorage, tenta carregar mesmo assim
// O loadUserProfile vai tratar o erro adequadamente se o email for necessário
// Só redireciona se o token estiver completamente inválido (sem UID e sem EID)
if (!usuarioData.email && usuarioData.UID === "0" && usuarioData.EID === "0") {
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || 'https://core.hgtx.com.br');
return;
}
}
// Se passou na verificação (ou tem token válido), carrega o perfil
loadUserProfile();
};
checkAuthAndLoad();
}, []);
const loadUserProfile = async () => {
try {
setLoading(true);
let userEmail = GlobalFunctions.getUsuarioLogado().email;
// Se não tem email, tenta obter do Transfer Area como fallback
if (!userEmail) {
const transferEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);
if (transferEmail) {
userEmail = transferEmail as string;
}
}
// Se ainda não tem email, tenta obter do token novamente após refresh
if (!userEmail) {
try {
await GlobalFunctions.getToken();
await new Promise(resolve => setTimeout(resolve, 500));
userEmail = GlobalFunctions.getUsuarioLogado().email;
} catch (error) {
console.error("MeuPerfil: Erro ao fazer refresh no loadUserProfile:", error);
}
}
if (!userEmail) {
console.error("MeuPerfil: Não foi possível obter email do usuário");
toast({
title: "Erro",
description: "Email do usuário não encontrado. Por favor, faça login novamente.",
variant: "destructive",
});
setLoading(false);
// Não redireciona aqui, deixa o usuário ver o erro
return;
}
setEmail(userEmail);
const response = await personalAgent.getUserProfile(userEmail);
if (response.success && response.id) {
// Usuário existe
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
// Usuário não existe
setUserExists(false);
}
} catch (error: unknown) {
console.error("Erro ao carregar perfil:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("não está cadastrado")) {
setUserExists(false);
} else {
toast({
title: "Erro",
description: errorMessage || "Erro ao carregar perfil do usuário.",
variant: "destructive",
});
}
} finally {
setLoading(false);
}
};
const handleWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setWhatsapp(formatted);
};
const handleCreateWhatsappChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setCreateWhatsapp(formatted);
};
const handleSave = async () => {
if (!userExists || !userId) {
toast({
title: "Erro",
description: "Usuário não encontrado. Por favor, crie uma conta primeiro.",
variant: "destructive",
});
return;
}
try {
setSaving(true);
const response = await personalAgent.updateUser(userId, {
nome: nomeCompleto,
whatsapp: whatsapp,
followup: receberLembretes,
});
if (response.success) {
toast({
title: "Perfil atualizado",
description: "Suas informações foram salvas com sucesso.",
});
// Atualiza os dados locais
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
} else {
throw new Error(response.message || "Erro ao atualizar perfil");
}
} catch (error: unknown) {
console.error("Erro ao salvar:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao salvar",
description: errorMessage || "Erro ao atualizar perfil. Tente novamente.",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
const handleCreateUser = async () => {
if (!createNome.trim()) {
toast({
title: "Erro",
description: "Nome é obrigatório.",
variant: "destructive",
});
return;
}
if (!createWhatsapp.trim()) {
toast({
title: "Erro",
description: "WhatsApp é obrigatório.",
variant: "destructive",
});
return;
}
try {
setCreating(true);
const response = await personalAgent.createUser({
nome: createNome.trim(),
email: email,
whatsapp: createWhatsapp,
followup: createFollowup,
});
if (response.success && response.id) {
toast({
title: "Conta criada",
description: "Sua conta foi criada com sucesso!",
});
setUserExists(true);
setUserId(response.id);
setNomeCompleto(response.nome);
setWhatsapp(formatPhoneNumber(response.whatsapp));
setReceberLembretes(response.followup);
setShowCreateModal(false);
// Limpa os campos do modal
setCreateNome("");
setCreateWhatsapp("");
setCreateFollowup(true);
} else {
throw new Error(response.message || "Erro ao criar conta");
}
} catch (error: unknown) {
console.error("Erro ao criar usuário:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
toast({
title: "Erro ao criar conta",
description: errorMessage || "Erro ao criar conta. Tente novamente.",
variant: "destructive",
});
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-muted-foreground">Carregando perfil...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Meu Perfil</h1>
<p className="text-muted-foreground mt-1">
Gerencie suas informações pessoais
</p>
</div>
{!userExists ? (
<Card className="max-w-2xl">
<CardContent className="py-12 text-center">
<div className="space-y-4">
<User className="w-16 h-16 mx-auto text-muted-foreground" />
<div>
<h3 className="text-xl font-semibold mb-2">
Você ainda não possui uma conta
</h3>
<p className="text-muted-foreground mb-6">
Crie sua conta para começar a usar o agente pessoal de IA
</p>
</div>
<Button onClick={() => setShowCreateModal(true)} size="lg">
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Informações Pessoais
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="nomeCompleto" className="flex items-center gap-2">
<User className="w-4 h-4" />
Nome Completo
</Label>
<Input
id="nomeCompleto"
value={nomeCompleto}
onChange={(e) => setNomeCompleto(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
E-mail
</Label>
<Input
id="email"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail não pode ser alterado
</p>
</div>
<div className="space-y-2">
<Label htmlFor="whatsapp" className="flex items-center gap-2">
<Phone className="w-4 h-4" />
WhatsApp
</Label>
<Input
id="whatsapp"
value={whatsapp}
onChange={handleWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="receberLembretes" className="flex items-center gap-2 cursor-pointer">
<Bell className="w-4 h-4" />
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="receberLembretes"
checked={receberLembretes}
onCheckedChange={setReceberLembretes}
/>
</div>
<Button
onClick={handleSave}
className="w-full sm:w-auto"
disabled={saving}
>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Salvar Alterações
</>
)}
</Button>
</CardContent>
</Card>
)}
{/* Modal de Criação de Conta */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Criar Conta</DialogTitle>
<DialogDescription>
Preencha os dados abaixo para criar sua conta no agente pessoal de IA
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="createNome">Nome Completo</Label>
<Input
id="createNome"
value={createNome}
onChange={(e) => setCreateNome(e.target.value)}
placeholder="Digite seu nome completo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="createEmail">E-mail</Label>
<Input
id="createEmail"
type="email"
value={email}
readOnly
className="bg-muted cursor-not-allowed"
/>
<p className="text-xs text-muted-foreground">
O e-mail é baseado na sua conta logada
</p>
</div>
<div className="space-y-2">
<Label htmlFor="createWhatsapp">WhatsApp</Label>
<Input
id="createWhatsapp"
value={createWhatsapp}
onChange={handleCreateWhatsappChange}
placeholder="(00) 00000-0000"
maxLength={15}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="createFollowup" className="cursor-pointer">
Receber Follow up e lembretes
</Label>
<p className="text-xs text-muted-foreground">
Receba notificações sobre eventos e tarefas
</p>
</div>
<Switch
id="createFollowup"
checked={createFollowup}
onCheckedChange={setCreateFollowup}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateModal(false)}
disabled={creating}
>
Cancelar
</Button>
<Button onClick={handleCreateUser} disabled={creating}>
{creating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Criando...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Criar Conta
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default MeuPerfil;
@@ -0,0 +1,24 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
}, [location.pathname]);
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
+34 -16
View File
@@ -2,10 +2,14 @@ import { useState } from "react";
import { Layout } from "@/components/Layout";
import { ChatView } from "@/components/chat/ChatView";
import { ImageView } from "@/components/images/ImageView";
import { AudioView } from "@/components/audio/AudioView";
import { TranscriptionView } from "@/components/audio/TranscriptionView";
import { GenerationView } from "@/components/audio/GenerationView";
import { BotView } from "@/components/bots/BotView";
import { BotChat } from "@/components/bots/BotChat";
import { AgentView } from "@/components/agent/AgentView";
import { Navigate, Route, Routes } from "react-router-dom";
import React from "react";
import { GlobalFunctions } from "@/GlobalFunctions";
interface Bot {
id: string;
@@ -15,7 +19,7 @@ interface Bot {
}
const Index = () => {
const [activeView, setActiveView] = useState<"chat" | "images" | "audio" | "bots" | "agent">("chat");
const [activeView, setActiveView] = useState<"chat" | "images" | "transcription" | "generation" | "bots" | "agent">("chat");
const [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
const handleStartBotChat = (bot: Bot) => {
@@ -26,20 +30,34 @@ const Index = () => {
setActiveBotChat(null);
};
return (
<Layout activeTab={activeView} onTabChange={setActiveView}>
{activeView === "chat" && <ChatView />}
{activeView === "images" && <ImageView />}
{activeView === "audio" && <AudioView />}
{activeView === "bots" && (
activeBotChat ? (
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
) : (
<BotView onStartChat={handleStartBotChat} />
)
)}
{activeView === "agent" && <AgentView />}
</Layout>
React.useEffect(() => {
if(!GlobalFunctions.isUsuarioLogado())window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
},[]);
return (<Routes>
<Route path="" element={<Navigate to={`/404`} replace />} />
<Route path="codex">
<Route path="" element={
<Layout activeTab={activeView} onTabChange={setActiveView}>
{activeView === "chat" && <ChatView />}
{activeView === "images" && <ImageView />}
{activeView === "transcription" && <TranscriptionView />}
{activeView === "generation" && <GenerationView />}
{activeView === "bots" && (
activeBotChat ? (
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
) : (
<BotView onStartChat={handleStartBotChat} />
)
)}
{activeView === "agent" && <AgentView />}
</Layout>
} />
</Route>
<Route path="*" element={<Navigate to={`/404`} replace />} />
</Routes>
);
};
+48
View File
@@ -0,0 +1,48 @@
import FullScreenLoader from "@/components/FullScreenLoader";
import { GlobalFunctions } from "@/GlobalFunctions";
import { RedirectService } from "./RedirectService";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
export default function Redirect() {
const navigate = useNavigate();
let { gsikey } = useParams();
React.useEffect(() => {
let error: number = 0;
sessionStorage.setItem('gsikey', gsikey ?? '0');
RedirectService.loadTransferArea(parseInt(gsikey ?? '0')).then((response) => {
//console.log(response);
error = response.data.errorCode;
//msg = response.data.errorMessage;
if(response.data.errorCode == 0){
sessionStorage.setItem('TRANSFER_AREA',JSON.stringify(response.data.areTrs));
sessionStorage.setItem('usuarioLogado',JSON.stringify({
token: response.data.jwt.token,
refreshToken: response.data.jwt.refreshToken
}));
}
// else if(error !== 1401){
// navigate('/',{replace: true});
// }
})
.catch((_err) => {
navigate('/404',{replace: true});
})
.finally(() => {
if(error == 0 || error === 1401){
navigate(`/${GlobalFunctions.getRedirectComponent()}`,{replace: true});
}
// else if(error === 1401){
// navigate({pathname: '/',search:`?p=${window.btoa(msg)}`},{replace: true});
// }
});
}, []);
return (
<FullScreenLoader show={true} text="Carregando...Aguarde..." />
);
}
+34
View File
@@ -0,0 +1,34 @@
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import axios from "axios";
const endPoint: string = 'v1/login/';
export class RedirectService{
public static loadTransferArea(gsikey: number): Promise<any>{
return axios.post(`${import.meta.env.VITE_BASE_URL_OPEN_CODEX_APIs}${endPoint}login_by_gsikey`,{
"gsikey" : gsikey
});
}
public static async validaAcessoApp(urlDestino: string): Promise<any>{
//let token = await GlobalFunctions.getToken();
let usuLogado = GlobalFunctions.getUsuarioLogado();
let usuCod: string = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioCodigo);
let estCod: string = usuLogado.EID;
let urlDestinoPost: string = urlDestino;//urlDestino.trim().length < 1 ? GlobalFunctions.getTransferProperty(TransferAreaProperties.urlDestino) : urlDestino;
if(urlDestinoPost.trim().length < 1){
try {
let pathSplit = window.location.pathname.split('/');
urlDestinoPost = `/${pathSplit[pathSplit.length-2]}/${pathSplit[pathSplit.length-1]}`;
} catch (error) {
}
}
//const heads = { headers: {'Content-Type':'application/json; charset=utf-8','Authorization': `Bearer ${token}`} };
const heads = { headers: {'Content-Type':'application/json; charset=utf-8'} };
return axios.post(`${import.meta.env.VITE_BASE_URL_HGTX_CORE_API}v1/aplicativos/validar_acesso_app?usuCod=${usuCod}&estCod=${estCod}`,{
"urlDestino": urlDestinoPost
},heads);
}
}
+384
View File
@@ -0,0 +1,384 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Interface para resposta do POST de parecer técnico
*/
export interface CreateOpinionResponse {
success: boolean;
id?: string;
file_url?: string;
file_url_melhoria?: string;
}
/**
* Interface para requisição de criação de parecer
*/
export interface CreateOpinionRequest {
titulo: string;
categoria: string;
instrucoes: string;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Status do parecer
*/
export type OpinionStatus = 'processando' | 'concluido' | 'erro';
/**
* Interface para um parecer retornado pela API
*/
export interface OpinionRecord {
id: string;
estabelecimento_id: number;
user_email: string;
titulo: string;
categoria: string;
instrucoes: string;
file_url: string;
created_at: string;
file_url_melhoria: string;
status?: OpinionStatus; // Status do processamento do parecer (pode vir da API ou ser local)
isLocalPending?: boolean; // Flag para indicar se é um registro local temporário
}
/**
* Interface para parâmetros de paginação e busca
*/
export interface GetOpinionsParams {
page?: number;
per_page?: number;
search?: string;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Interface para resposta do GET de pareceres
*/
export interface GetOpinionsResponse {
data: OpinionRecord[];
total: number;
page: number;
per_page: number;
}
/**
* Serviço para gerenciamento de pareceres jurídicos
*/
class AgentService {
private readonly CREATE_OPINION_ENDPOINT = '/webhook/codex/gepam/parecer-tecnico';
private readonly GET_OPINIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_parecer';
/**
* Cria um novo parecer técnico
*
* @param request - Dados do parecer
* @returns Promise com a resposta da API
*/
async createOpinion(request: CreateOpinionRequest): Promise<CreateOpinionResponse> {
const {
titulo,
categoria,
instrucoes,
userEmail,
estabelecimentoId
} = request;
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
// Validações
if (!titulo || titulo.trim().length === 0) {
throw {
success: false,
message: 'Título do parecer é obrigatório',
};
}
if (!instrucoes || instrucoes.trim().length === 0) {
throw {
success: false,
message: 'Instruções são obrigatórias',
};
}
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!estabId) {
throw {
success: false,
message: 'ID do estabelecimento não fornecido',
};
}
// Log para debug
console.log('Criando parecer:', {
titulo,
categoria,
instrucoesLength: instrucoes.length,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API com timeout de 5 minutos
const response = await apiService.post<CreateOpinionResponse>(
this.CREATE_OPINION_ENDPOINT,
{
user_email: email,
estabelecimento_id: estabId,
titulo: titulo.trim(),
categoria: categoria.trim(),
instrucoes: instrucoes.trim(),
},
{
timeout: 600000, // 5 minutos para geração de parecer (processo demorado)
}
);
console.log('Resposta da API (criar parecer):', response.data);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro ao criar parecer:', error);
throw {
success: false,
message: error.message || 'Erro ao criar parecer',
status: error.status,
};
}
}
/**
* Busca todos os pareceres do usuário com paginação e busca
*
* @param params - Parâmetros de paginação e busca
* @returns Promise com array de pareceres
*/
async getOpinions(params?: GetOpinionsParams): Promise<OpinionRecord[]> {
const {
page = 1,
per_page = 10,
search = '',
userEmail,
estabelecimentoId
} = params || {};
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
const estabId = estabelecimentoId || GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!estabId) {
throw {
success: false,
message: 'ID do estabelecimento não fornecido',
};
}
console.log('Buscando pareceres:', {
userEmail: email,
estabelecimentoId: estabId,
page,
per_page,
search,
});
try {
// Constrói a URL com parâmetros de query
const url = `${this.GET_OPINIONS_ENDPOINT}/${email}/${estabId}`;
const response = await apiService.get<OpinionRecord[]>(url, {
params: {
page,
per_page,
search,
},
});
console.log('Resposta da API (buscar pareceres):', response.data);
// A API retorna diretamente o array de pareceres
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto, tenta encontrar o array dentro dele
console.warn('API retornou objeto em vez de array:', response.data);
if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
} else if (Array.isArray((response.data as any).opinions)) {
return (response.data as any).opinions;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de pareceres da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar pareceres:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar pareceres',
status: error.status,
};
}
}
/**
* Faz download de um arquivo de parecer
*
* @param fileUrl - URL do arquivo a ser baixado
* @param fileName - Nome do arquivo para download
*/
async downloadOpinion(fileUrl: string, fileName: string): Promise<void> {
if (!fileUrl) {
throw {
success: false,
message: 'URL do arquivo não fornecida',
};
}
try {
console.log('Iniciando download:', { fileUrl, fileName });
// Tenta primeiro fazer o download via fetch (funciona se CORS estiver configurado)
try {
const response = await fetch(fileUrl, {
method: 'GET',
mode: 'cors',
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`Erro HTTP: ${response.status}`);
}
// Converte a resposta em blob
const blob = await response.blob();
// Cria uma URL temporária para o blob
const blobUrl = URL.createObjectURL(blob);
// Cria um elemento <a> temporário para forçar o download
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
// Remove o elemento e libera a URL temporária
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
console.log('Download via fetch concluído:', { fileUrl, fileName });
return;
} catch (fetchError: any) {
console.warn('Erro no download via fetch, tentando método alternativo:', fetchError.message);
// Se falhar (erro de CORS), usa o método alternativo de abrir em nova aba
// Isso permite que o navegador force o download mesmo com restrições de CORS
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Para S3, podemos tentar adicionar parâmetros que forçam o download
const url = new URL(fileUrl);
url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
link.href = url.toString();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log('Download via link direto iniciado:', { fileUrl, fileName });
}
} catch (error: any) {
console.error('Erro ao fazer download:', error);
throw {
success: false,
message: error.message || 'Erro ao fazer download do arquivo. Verifique se a URL está acessível.',
};
}
}
/**
* Exclui um parecer técnico
*
* @param opinionId - ID do parecer a ser excluído
* @param userEmail - Email do usuário (opcional, usa GlobalFunctions se não fornecido)
* @returns Promise com a resposta da API
*/
async deleteOpinion(opinionId: string, userEmail?: string): Promise<{ success: boolean }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!opinionId) {
throw {
success: false,
message: 'ID do parecer não fornecido',
};
}
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Excluindo parecer:', {
opinionId,
userEmail: email,
});
try {
// Constrói a URL com o user_email e id
const url = `/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_parecer/${email}/${opinionId}`;
const response = await apiService.delete<{ success: boolean }[]>(url);
console.log('Resposta da API (excluir parecer):', response.data);
// A API retorna um array com { success: true }
if (Array.isArray(response.data) && response.data.length > 0) {
return response.data[0];
}
return { success: true };
} catch (error: any) {
console.error('Erro ao excluir parecer:', error);
throw {
success: false,
message: error.message || 'Erro ao excluir parecer',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const agentService = new AgentService();
+127
View File
@@ -0,0 +1,127 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
/**
* Configuração centralizada da API
* Todas as chamadas de API devem usar este serviço para garantir
* autenticação e configuração consistente
*/
class ApiService {
private axiosInstance: AxiosInstance;
private apiKey: string;
private baseURL: string;
constructor() {
// Busca configurações das variáveis de ambiente
this.apiKey = import.meta.env.VITE_API_KEY || '';
this.baseURL = import.meta.env.VITE_API_BASE_URL || '';
// Validação das variáveis de ambiente
if (!this.apiKey) {
console.error('VITE_API_KEY não configurada no arquivo .env');
}
if (!this.baseURL) {
console.error('VITE_API_BASE_URL não configurada no arquivo .env');
}
// Cria instância do Axios com configurações padrão
this.axiosInstance = axios.create({
baseURL: this.baseURL,
timeout: 60000, // 60 segundos para upload de arquivos
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para adicionar API Key em todas as requisições
this.axiosInstance.interceptors.request.use(
(config) => {
// Adiciona a API Key no header de todas as requisições
if (this.apiKey) {
config.headers['apikey'] = this.apiKey;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor de resposta para tratamento centralizado de erros
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// Log de erro para debug
console.error('API Error:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
});
// Retorna erro formatado
return Promise.reject({
message: error.response?.data?.message || error.message || 'Erro ao comunicar com o servidor',
status: error.response?.status,
data: error.response?.data,
});
}
);
}
/**
* Requisição GET
*/
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(url, config);
}
/**
* Requisição POST
*/
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, data, config);
}
/**
* Requisição POST com FormData (para upload de arquivos)
*/
async postFormData<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(url, formData, {
...config,
headers: {
...config?.headers,
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Requisição PUT
*/
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.put<T>(url, data, config);
}
/**
* Requisição DELETE
*/
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(url, config);
}
/**
* Retorna a URL base configurada
*/
getBaseURL(): string {
return this.baseURL;
}
/**
* Retorna a instância do Axios (uso avançado)
*/
getInstance(): AxiosInstance {
return this.axiosInstance;
}
}
// Exporta instância única (Singleton)
export const apiService = new ApiService();
+344
View File
@@ -0,0 +1,344 @@
export interface AsanaWorkspaceResponse {
gid: string;
resource_type: string;
name: string;
}
export interface AsanaWorkspacesResponse {
data: AsanaWorkspaceResponse[];
}
export interface AsanaWorkspace {
id: string;
name: string;
}
export interface AsanaUserResponse {
gid: string;
name: string;
resource_type: string;
}
export interface AsanaUsersResponse {
data: AsanaUserResponse[];
}
export interface AsanaUser {
id: string;
name: string;
}
export interface AsanaIntegrationResponse {
success: boolean;
integracao_id?: string;
id?: string;
api_key?: string;
workspace_gid?: string;
workspace_nome?: string;
usuario_asana_gid?: string;
usuario_asana_nome?: string;
}
export interface AsanaIntegrationRequest {
user_id?: string;
api_key: string;
workspace_gid: string;
workspace_nome: string;
usuario_asana_gid: string;
usuario_asana_nome: string;
}
class AsanaService {
private readonly ASANA_BASE_URL = 'https://app.asana.com/api/1.0';
async getWorkspaces(token: string): Promise<AsanaWorkspace[]> {
if (!token || token.trim().length === 0) {
throw {
success: false,
message: 'Token do Asana é obrigatório',
};
}
try {
const axios = (await import('axios')).default;
const headers: Record<string, string> = {
'accept': 'application/json',
'authorization': `Bearer ${token.trim()}`,
};
const response = await axios.get<AsanaWorkspacesResponse>(
`${this.ASANA_BASE_URL}/workspaces`,
{ headers }
);
return response.data.data.map((workspace) => ({
id: workspace.gid,
name: workspace.name,
}));
} catch (error: unknown) {
console.error('Erro ao buscar workspaces do Asana:', error);
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: any; status?: number } };
if (axiosError.response?.status === 401) {
throw {
success: false,
message: 'Token inválido ou expirado. Verifique sua chave de API.',
};
}
if (axiosError.response?.data) {
throw {
success: false,
message: axiosError.response.data.message || 'Erro ao buscar workspaces do Asana',
};
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao buscar workspaces do Asana',
};
}
}
async getUsers(token: string, workspaceId: string): Promise<AsanaUser[]> {
if (!token || token.trim().length === 0) {
throw {
success: false,
message: 'Token do Asana é obrigatório',
};
}
if (!workspaceId || workspaceId.trim().length === 0) {
throw {
success: false,
message: 'ID do workspace é obrigatório',
};
}
try {
const axios = (await import('axios')).default;
const headers: Record<string, string> = {
'accept': 'application/json',
'authorization': `Bearer ${token.trim()}`,
};
const response = await axios.get<AsanaUsersResponse>(
`${this.ASANA_BASE_URL}/users?workspace=${workspaceId}`,
{ headers }
);
// Converte a resposta da API para o formato esperado pelo componente
// A API retorna gid, mas o componente espera id
return response.data.data.map((user) => ({
id: user.gid,
name: user.name,
}));
} catch (error: unknown) {
console.error('Erro ao buscar usuários do Asana:', error);
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: any; status?: number } };
if (axiosError.response?.status === 401) {
throw {
success: false,
message: 'Token inválido ou expirado. Verifique sua chave de API.',
};
}
if (axiosError.response?.data) {
throw {
success: false,
message: axiosError.response.data.message || 'Erro ao buscar usuários do Asana',
};
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao buscar usuários do Asana',
};
}
}
async getIntegration(userId: string): Promise<AsanaIntegrationResponse | null> {
if (!userId || userId.trim().length === 0) {
throw {
success: false,
message: 'ID do usuário é obrigatório',
};
}
try {
const axios = (await import('axios')).default;
const { GlobalFunctions } = await import('@/GlobalFunctions');
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'accept': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.get<AsanaIntegrationResponse>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${userId}`,
{ headers }
);
if (!response.data.success) {
return null;
}
return response.data;
} catch (error: unknown) {
console.error('Erro ao buscar integração do Asana:', error);
// Se o erro for 404 ou similar, significa que não tem integração cadastrada
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { status?: number } };
if (axiosError.response?.status === 404) {
return null;
}
}
return null;
}
}
async createIntegration(request: AsanaIntegrationRequest): Promise<AsanaIntegrationResponse> {
if (!request.user_id || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
throw {
success: false,
message: 'Dados incompletos para criar integração',
};
}
try {
const axios = (await import('axios')).default;
const { GlobalFunctions } = await import('@/GlobalFunctions');
// Obtém o token JWT para autenticação
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'accept': 'application/json',
};
// Adiciona API key (obrigatória)
if (apiKey) {
headers['apikey'] = apiKey;
}
// Adiciona token JWT se disponível
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.post<AsanaIntegrationResponse>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/integracoes/asana`,
request,
{ headers }
);
return response.data;
} catch (error: unknown) {
console.error('Erro ao criar integração do Asana:', error);
// Retorna o erro da API se existir
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: any; status?: number } };
if (axiosError.response?.data) {
const errorData = axiosError.response.data;
throw {
success: false,
message: errorData.message || 'Erro ao criar integração do Asana',
};
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao criar integração do Asana',
};
}
}
async updateIntegration(integracaoId: string, request: Omit<AsanaIntegrationRequest, 'user_id'>): Promise<AsanaIntegrationResponse> {
if (!integracaoId || !request.api_key || !request.workspace_gid || !request.usuario_asana_gid) {
throw {
success: false,
message: 'Dados incompletos para atualizar integração',
};
}
try {
const axios = (await import('axios')).default;
const { GlobalFunctions } = await import('@/GlobalFunctions');
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'accept': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/c898beff-84cb-44df-a69c-6eff27ccd7aa/codex/agente-pessoal/integracoes/asana/${integracaoId}`;
const response = await axios.post<AsanaIntegrationResponse>(
url,
request,
{ headers }
);
return response.data;
} catch (error: unknown) {
console.error('Erro ao atualizar integração do Asana:', error);
// Retorna o erro da API se existir
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: any; status?: number } };
if (axiosError.response?.data) {
const errorData = axiosError.response.data;
throw {
success: false,
message: errorData.message || 'Erro ao atualizar integração do Asana',
};
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao atualizar integração do Asana',
};
}
}
}
// Exporta instância única (Singleton)
export const asanaService = new AsanaService();
+360
View File
@@ -0,0 +1,360 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Tipos de vozes disponíveis para geração de áudio
*/
export type VoiceType = 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer';
/**
* Interface para a resposta da API de geração de áudio
*/
export interface AudioGenerationResponse {
success: boolean;
audio_url: string; // URL do áudio gerado
audio_generation_id: string;
message: string; // Texto que foi convertido em áudio
}
/**
* Interface para um áudio armazenado no banco de dados
*/
export interface AudioRecord {
id: string;
user_email: string;
estabelecimento_id: number;
input_text: string;
model: string;
voice: VoiceType;
audio_url: string;
duration_seconds: number | null;
file_size: number;
cost_usd: string;
created_at: string;
}
/**
* Interface para os dados necessários para geração de áudio
*/
export interface AudioGenerationRequest {
message: string;
voice: VoiceType;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Informações sobre cada tipo de voz disponível
*/
export const VOICE_OPTIONS = {
alloy: {
label: "Alloy",
gender: "Masculina",
style: "Neutra, equilibrada, tom corporativo",
description: "Boa para tutoriais e comunicações institucionais."
},
echo: {
label: "Echo",
gender: "Masculina",
style: "Forte e profissional, mais grave",
description: "Ideal para voz de autoridade ou locução firme."
},
fable: {
label: "Fable",
gender: "Feminina",
style: "Narrativa, calorosa e envolvente",
description: "Ótima para storytelling e áudios empáticos."
},
onyx: {
label: "Onyx",
gender: "Masculina",
style: "Grave, autoritária, impactante",
description: "Excelente para trailers, mensagens sérias ou institucionais."
},
nova: {
label: "Nova",
gender: "Feminina",
style: "Brilhante, animada, energética",
description: "Boa para vídeos curtos, marketing ou conteúdos leves."
},
shimmer: {
label: "Shimmer",
gender: "Feminina",
style: "Suave, otimista, clara",
description: "Boa para mensagens acolhedoras, explicações e IA conversacional."
}
} as const;
/**
* Serviço de geração de áudio (Text-to-Speech)
*/
class AudioGenerationService {
private readonly AUDIO_GENERATION_ENDPOINT = '/webhook/codex/gerar_audio';
private readonly GET_AUDIOS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_gerar_audios';
private readonly DELETE_AUDIO_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_gerar_audio';
/**
* Gera um arquivo de áudio a partir de texto
*
* @param request - Dados da requisição (texto, voz, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateAudio(request: AudioGenerationRequest): Promise<AudioGenerationResponse> {
const { message, voice, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida o texto
if (!message || message.trim().length === 0) {
throw {
success: false,
message: 'O texto não pode estar vazio',
};
}
// Valida a voz
if (!this.isValidVoice(voice)) {
throw {
success: false,
message: `Voz inválida. Opções disponíveis: ${Object.keys(VOICE_OPTIONS).join(', ')}`,
};
}
// Log para debug (remover em produção se necessário)
console.log('Gerando áudio:', {
messageLength: message.length,
voice,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.post<AudioGenerationResponse>(
this.AUDIO_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId,
user_email: email,
message: message,
voice: voice,
}
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na geração de áudio:', error);
throw {
success: false,
message: error.message || 'Erro ao gerar áudio',
status: error.status,
};
}
}
/**
* Valida se a voz selecionada é suportada
*
* @param voice - Voz a ser validada
* @returns true se a voz é válida
*/
isValidVoice(voice: string): voice is VoiceType {
return Object.keys(VOICE_OPTIONS).includes(voice);
}
/**
* Obtém informações sobre uma voz específica
*
* @param voice - Tipo de voz
* @returns Informações da voz
*/
getVoiceInfo(voice: VoiceType) {
return VOICE_OPTIONS[voice];
}
/**
* Lista todas as vozes disponíveis
*
* @returns Array com todas as opções de voz
*/
getAllVoices() {
return Object.entries(VOICE_OPTIONS).map(([key, info]) => ({
value: key as VoiceType,
...info,
}));
}
/**
* Valida o texto para geração de áudio
*
* @param text - Texto a ser validado
* @param maxLength - Comprimento máximo (padrão: 4096 caracteres)
* @returns Objeto com resultado da validação
*/
validateText(text: string, maxLength: number = 4096): { valid: boolean; error?: string } {
if (!text || text.trim().length === 0) {
return {
valid: false,
error: 'O texto não pode estar vazio',
};
}
if (text.length > maxLength) {
return {
valid: false,
error: `O texto é muito longo. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true };
}
/**
* Lista áudios do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de áudios
*/
async getAudios(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<AudioRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando áudios:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<AudioRecord[]>(
`${this.GET_AUDIOS_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de áudios
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'audios' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).audios)) {
return (response.data as any).audios;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de áudios da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar áudios:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar áudios',
status: error.status,
};
}
}
/**
* Deleta um áudio do banco de dados
*
* @param audioId - ID do áudio a ser deletado
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteAudio(audioId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!audioId) {
throw {
success: false,
message: 'ID do áudio não fornecido',
};
}
console.log('Deletando áudio:', {
audioId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_AUDIO_ENDPOINT}/${email}/${audioId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Áudio deletado com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar áudio:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar áudio',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const audioGenerationService = new AudioGenerationService();
+988
View File
@@ -0,0 +1,988 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Interface para a resposta da API de chat
*/
export interface ChatResponse {
success: boolean;
response: string; // Mensagem da IA
chat_id: string; // ID do chat retornado pela API (importante para manter contexto)
}
/**
* Interface para os dados necessários para enviar mensagem
*/
export interface ChatMessageRequest {
chatId: string;
message: string;
modelId: string;
personalidade?: string;
anexos?: File[]; // Array de até 5 arquivos
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Interface para armazenamento local de conversas
* Preparando para futura integração com banco de dados
*/
export interface StoredChat {
id: string;
title: string;
createdAt: Date;
updatedAt: Date;
model: string;
systemPrompt: string;
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
model?: string;
timestamp: Date;
attachments?: Array<{
name: string;
type: string;
size: number;
}>;
}>;
}
/**
* Interface para armazenamento de pastas
* Preparando para futura integração com banco de dados
*/
export interface StoredFolder {
id: string;
name: string;
createdAt: Date;
chatIds: string[];
}
/**
* Interface para Folder no banco de dados
*/
export interface FolderRecord {
id: string;
name: string;
created_at: string;
updated_at: string;
}
/**
* Interface para Chat no banco de dados
*/
export interface ChatRecord {
id: string;
title: string;
model_id: number;
folder_id: string | null;
created_at: string;
updated_at: string;
personalidade: string; // Personalidade/prompt do sistema salvo no banco
estabelecimento_id: number;
}
/**
* Interface para resposta do GET de chats e folders
*/
export interface GetChatsAndFoldersResponse {
chats: ChatRecord[];
folders: FolderRecord[];
}
/**
* Interface para Message no banco de dados
*/
export interface MessageRecord {
id: string;
chat_id: string;
role: 'user' | 'assistant';
content: string;
model_id: number;
model_name?: string; // Nome do modelo retornado pela API
has_attachments: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost_usd: string;
created_at: string;
}
/**
* Serviço de chat com IA
*/
class ChatService {
private readonly CHAT_ENDPOINT = '/webhook/codex/message';
private readonly STORAGE_KEY_CHATS = 'hgtx_chats';
private readonly STORAGE_KEY_FOLDERS = 'hgtx_folders';
// Endpoints para folders e chats
private readonly POST_FOLDER_ENDPOINT = '/webhook/codex/post_folders';
private readonly GET_CHATS_FOLDERS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_folders';
private readonly PUT_CHAT_IN_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/insert_chat_in_folder';
private readonly DELETE_FOLDER_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_folder';
private readonly DELETE_CHAT_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_chat_messages';
private readonly GET_CHAT_MESSAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_chat_messages';
// Formatos de arquivo permitidos (atualmente)
private readonly ALLOWED_FILE_TYPES = {
// Formatos ativos
'application/pdf': { ext: '.pdf', label: 'PDF' },
'image/png': { ext: '.png', label: 'PNG' },
'image/jpeg': { ext: '.jpg, .jpeg', label: 'JPEG' },
'image/webp': { ext: '.webp', label: 'WebP' },
// Formatos futuros (desabilitados por enquanto)
// 'text/csv': { ext: '.csv', label: 'CSV' },
// 'text/plain': { ext: '.txt', label: 'TXT' },
// 'application/json': { ext: '.json', label: 'JSON' },
// 'application/vnd.ms-excel': { ext: '.xls', label: 'XLS' },
// 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { ext: '.xlsx', label: 'XLSX' },
};
// Número máximo de anexos permitidos
private readonly MAX_ATTACHMENTS = 5;
/**
* Gera um chat_id único e seguro usando hash
* Formato: timestamp + random + hash
*
* NOTA: Esta função está mantida para compatibilidade e uso no localStorage,
* mas para comunicação com a API, o fluxo correto é:
* 1. Enviar chat_id: "0" na primeira mensagem
* 2. API retorna o chat_id real
* 3. Usar o chat_id retornado nas próximas mensagens
*
* @returns string - ID único para o chat (uso local)
*/
generateChatId(): string {
const timestamp = Date.now().toString(36);
const randomPart = Math.random().toString(36).substring(2, 15);
const randomPart2 = Math.random().toString(36).substring(2, 15);
// Combina timestamp e partes aleatórias para criar ID único
const chatId = `chat_${timestamp}_${randomPart}${randomPart2}`;
return chatId;
}
/**
* Envia mensagem para a API de chat
*
* @param request - Dados da requisição (mensagem, modelo, anexos, etc)
* @returns Promise com a resposta da API
*/
async sendMessage(request: ChatMessageRequest): Promise<ChatResponse> {
const {
chatId,
message,
modelId,
personalidade,
anexos,
userEmail,
estabelecimentoId
} = request;
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
// Validações
if (!chatId) {
throw {
success: false,
message: 'ID do chat é obrigatório',
};
}
if (!message || message.trim().length === 0) {
throw {
success: false,
message: 'A mensagem não pode estar vazia',
};
}
if (!modelId) {
throw {
success: false,
message: 'Modelo da IA é obrigatório',
};
}
// Valida número de anexos
if (anexos && anexos.length > 5) {
throw {
success: false,
message: 'Máximo de 5 arquivos anexos permitidos',
};
}
// Cria FormData para envio multipart
const formData = new FormData();
formData.append('estabelecimento_id', estabId.toString());
formData.append('chat_id', chatId);
formData.append('user_email', email);
formData.append('model_id', modelId);
formData.append('message', message);
// Adiciona personalidade se fornecida
if (personalidade && personalidade.trim().length > 0) {
formData.append('personalidade', personalidade);
}
// Adiciona anexos (máximo 5)
if (anexos && anexos.length > 0) {
anexos.forEach((file, index) => {
if (index < 5) {
formData.append(`anexo${index + 1}`, file);
}
});
}
// Log para debug (remover em produção se necessário)
console.log('Enviando mensagem:', {
chatId,
messageLength: message.length,
modelId,
hasPersonalidade: !!personalidade,
attachmentsCount: anexos?.length || 0,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.postFormData<ChatResponse>(
this.CHAT_ENDPOINT,
formData
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro ao enviar mensagem:', error);
throw {
success: false,
message: error.message || 'Erro ao comunicar com a IA',
status: error.status,
};
}
}
/**
* Salva ou atualiza um chat no localStorage
* Preparado para futura migração para banco de dados
*
* @param chat - Chat a ser salvo
*/
saveChat(chat: StoredChat): void {
try {
const chats = this.getAllChats();
const existingIndex = chats.findIndex(c => c.id === chat.id);
if (existingIndex >= 0) {
chats[existingIndex] = {
...chat,
updatedAt: new Date(),
};
} else {
chats.push(chat);
}
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(chats));
} catch (error) {
console.error('Erro ao salvar chat:', error);
throw new Error('Não foi possível salvar o chat');
}
}
/**
* Busca um chat específico por ID
*
* @param chatId - ID do chat
* @returns Chat encontrado ou undefined
*/
getChat(chatId: string): StoredChat | undefined {
const chats = this.getAllChats();
return chats.find(c => c.id === chatId);
}
/**
* Retorna todos os chats salvos
*
* @returns Array de chats
*/
getAllChats(): StoredChat[] {
try {
const chatsJson = localStorage.getItem(this.STORAGE_KEY_CHATS);
if (!chatsJson) return [];
const chats = JSON.parse(chatsJson);
// Converte strings de data para objetos Date
return chats.map((chat: any) => ({
...chat,
createdAt: new Date(chat.createdAt),
updatedAt: new Date(chat.updatedAt),
messages: chat.messages.map((msg: any) => ({
...msg,
timestamp: new Date(msg.timestamp),
})),
}));
} catch (error) {
console.error('Erro ao carregar chats:', error);
return [];
}
}
/**
* Deleta um chat do localStorage
*
* @param chatId - ID do chat a ser deletado
*/
deleteChatLocal(chatId: string): void {
try {
const chats = this.getAllChats();
const filteredChats = chats.filter(c => c.id !== chatId);
localStorage.setItem(this.STORAGE_KEY_CHATS, JSON.stringify(filteredChats));
} catch (error) {
console.error('Erro ao deletar chat:', error);
throw new Error('Não foi possível deletar o chat');
}
}
/**
* Salva uma pasta no localStorage
*
* @param folder - Pasta a ser salva
*/
saveFolder(folder: StoredFolder): void {
try {
const folders = this.getAllFolders();
const existingIndex = folders.findIndex(f => f.id === folder.id);
if (existingIndex >= 0) {
folders[existingIndex] = folder;
} else {
folders.push(folder);
}
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(folders));
} catch (error) {
console.error('Erro ao salvar pasta:', error);
throw new Error('Não foi possível salvar a pasta');
}
}
/**
* Retorna todas as pastas salvas
*
* @returns Array de pastas
*/
getAllFolders(): StoredFolder[] {
try {
const foldersJson = localStorage.getItem(this.STORAGE_KEY_FOLDERS);
if (!foldersJson) return [];
const folders = JSON.parse(foldersJson);
// Converte strings de data para objetos Date
return folders.map((folder: any) => ({
...folder,
createdAt: new Date(folder.createdAt),
}));
} catch (error) {
console.error('Erro ao carregar pastas:', error);
return [];
}
}
/**
* Deleta uma pasta do localStorage
*
* @param folderId - ID da pasta a ser deletada
*/
deleteFolderLocal(folderId: string): void {
try {
const folders = this.getAllFolders();
const filteredFolders = folders.filter(f => f.id !== folderId);
localStorage.setItem(this.STORAGE_KEY_FOLDERS, JSON.stringify(filteredFolders));
} catch (error) {
console.error('Erro ao deletar pasta:', error);
throw new Error('Não foi possível deletar a pasta');
}
}
/**
* Gera título automático para o chat baseado na primeira mensagem
*
* @param message - Primeira mensagem do usuário
* @param maxLength - Comprimento máximo do título
* @returns Título gerado
*/
generateChatTitle(message: string, maxLength: number = 50): string {
if (!message || message.trim().length === 0) {
return 'Nova Conversa';
}
const trimmed = message.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength) + '...';
}
/**
* Limpa todas as conversas (usar com cuidado)
*/
clearAllChats(): void {
localStorage.removeItem(this.STORAGE_KEY_CHATS);
}
/**
* Limpa todas as pastas (usar com cuidado)
*/
clearAllFolders(): void {
localStorage.removeItem(this.STORAGE_KEY_FOLDERS);
}
/**
* Valida anexos antes de enviar para a API
*
* @param files - Array de arquivos a serem validados
* @returns Objeto com resultado da validação
*/
validateAttachments(files: File[]): { valid: boolean; error?: string } {
// Valida número de anexos
if (files.length > this.MAX_ATTACHMENTS) {
return {
valid: false,
error: `Máximo de ${this.MAX_ATTACHMENTS} anexos permitidos. Você selecionou ${files.length}.`,
};
}
// Valida cada arquivo
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileType = file.type;
const fileName = file.name;
// Verifica se o tipo MIME é permitido
if (!this.ALLOWED_FILE_TYPES[fileType as keyof typeof this.ALLOWED_FILE_TYPES]) {
// Tenta validar pela extensão também
const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
const isExtensionValid = Object.values(this.ALLOWED_FILE_TYPES).some(
type => type.ext.includes(extension)
);
if (!isExtensionValid) {
const allowedFormats = Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.label)
.join(', ');
return {
valid: false,
error: `Arquivo "${fileName}" não é permitido. Formatos aceitos: ${allowedFormats}`,
};
}
}
}
return { valid: true };
}
/**
* Retorna lista de formatos de arquivo permitidos
*
* @returns Array com informações dos formatos
*/
getAllowedFileTypes() {
return Object.entries(this.ALLOWED_FILE_TYPES).map(([mimeType, info]) => ({
mimeType,
...info,
}));
}
/**
* Retorna string formatada com formatos permitidos (para exibição)
*
* @returns String formatada (ex: "PDF, PNG, JPEG, WebP")
*/
getAllowedFileTypesLabel(): string {
return Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.label)
.join(', ');
}
/**
* Retorna string com extensões para usar no input file accept
*
* @returns String formatada (ex: ".pdf,.png,.jpg,.jpeg,.webp")
*/
getAllowedFileExtensions(): string {
return Object.values(this.ALLOWED_FILE_TYPES)
.map(t => t.ext)
.join(',');
}
/**
* Retorna número máximo de anexos permitidos
*/
getMaxAttachments(): number {
return this.MAX_ATTACHMENTS;
}
// ===== MÉTODOS DE INTEGRAÇÃO COM BANCO DE DADOS =====
/**
* Cria uma nova pasta no banco de dados
*
* @param name - Nome da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async createFolder(name: string, userEmail?: string): Promise<{ success: boolean; folder?: FolderRecord }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!name || name.trim().length === 0) {
throw {
success: false,
message: 'Nome da pasta não pode estar vazio',
};
}
console.log('Criando pasta:', {
name,
userEmail: email,
});
try {
const response = await apiService.post<Array<{ success: boolean; user_email?: string; name?: string }>>(
this.POST_FOLDER_ENDPOINT,
{
user_email: email,
name: name.trim(),
}
);
console.log('Resposta completa do POST:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true, ...}]
let result: { success: boolean; user_email?: string; name?: string };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data as any;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao criar pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao criar pasta',
status: error.status,
};
}
}
/**
* Renomeia uma pasta existente
*
* @param folderId - ID da pasta a ser renomeada
* @param newName - Novo nome da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com o resultado da operação
*/
async renameFolder(folderId: string, newName: string, userEmail?: string): Promise<{ success: boolean; folder?: FolderRecord }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!folderId || folderId.trim().length === 0) {
throw {
success: false,
message: 'ID da pasta não pode estar vazio',
};
}
if (!newName || newName.trim().length === 0) {
throw {
success: false,
message: 'Nome da pasta não pode estar vazio',
};
}
console.log('Renomeando pasta:', {
folderId,
newName,
userEmail: email,
});
try {
const response = await apiService.put<Array<{ success: boolean; user_email?: string; name?: string; id?: string }>>(
this.POST_FOLDER_ENDPOINT,
{
id: folderId,
user_email: email,
name: newName.trim(),
}
);
console.log('Resposta completa do PUT (renomear pasta):', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true, "user_email": "...", "name": "...", "id": "..."}]
let result: { success: boolean; user_email?: string; name?: string; id?: string };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data as any;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao renomear pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao renomear pasta',
status: error.status,
};
}
}
/**
* Busca todos os chats e pastas do usuário
*
* @param userEmail - Email do usuário (opcional)
* @returns Promise com chats e folders
*/
async getChatsAndFolders(userEmail?: string): Promise<GetChatsAndFoldersResponse> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando chats e pastas:', {
userEmail: email,
});
try {
const response = await apiService.get<Array<{ result: GetChatsAndFoldersResponse }>>(
`${this.GET_CHATS_FOLDERS_ENDPOINT}/${email}`
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto "result": [{"result": {"chats": [...], "folders": [...]}}]
let result: GetChatsAndFoldersResponse;
if (Array.isArray(response.data) && response.data.length > 0) {
result = response.data[0].result;
console.log('API retornou array com result:', result);
} else if ((response.data as any).result) {
result = (response.data as any).result;
console.log('API retornou objeto com result:', result);
} else {
// Fallback: retorna vazio
console.warn('Estrutura inesperada da resposta');
result = { chats: [], folders: [] };
}
// Garante que chats e folders são arrays
return {
chats: Array.isArray(result.chats) ? result.chats : [],
folders: Array.isArray(result.folders) ? result.folders : [],
};
} catch (error: any) {
console.error('Erro ao buscar chats e pastas:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar chats e pastas',
status: error.status,
};
}
}
/**
* Move um chat para dentro de uma pasta
*
* @param chatId - ID do chat
* @param folderId - ID da pasta
* @returns Promise com sucesso ou erro
*/
async moveChatToFolder(chatId: string, folderId: string): Promise<{ success: boolean }> {
if (!chatId || !folderId) {
throw {
success: false,
message: 'Chat ID e Folder ID são obrigatórios',
};
}
console.log('Movendo chat para pasta:', {
chatId,
folderId,
});
try {
const response = await apiService.put<Array<{ success: boolean }>>(
`${this.PUT_CHAT_IN_FOLDER_ENDPOINT}/${folderId}/${chatId}`
);
console.log('Resposta completa do PUT:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao mover chat para pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao mover chat para pasta',
status: error.status,
};
}
}
/**
* Deleta uma pasta do banco de dados
*
* @param folderId - ID da pasta
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteFolder(folderId: string, userEmail?: string): Promise<{ success: boolean }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!folderId) {
throw {
success: false,
message: 'ID da pasta não fornecido',
};
}
console.log('Deletando pasta:', {
folderId,
userEmail: email,
});
try {
const response = await apiService.delete<Array<{ success: boolean }>>(
`${this.DELETE_FOLDER_ENDPOINT}/${email}/${folderId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao deletar pasta:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar pasta',
status: error.status,
};
}
}
/**
* Deleta um chat do banco de dados
*
* @param chatId - ID do chat
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteChat(chatId: string, userEmail?: string): Promise<{ success: boolean }> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!chatId) {
throw {
success: false,
message: 'ID do chat não fornecido',
};
}
console.log('Deletando chat:', {
chatId,
userEmail: email,
});
try {
const response = await apiService.delete<Array<{ success: boolean }>>(
`${this.DELETE_CHAT_ENDPOINT}/${email}/${chatId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean };
if (Array.isArray(response.data)) {
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
result = response.data;
console.log('API retornou objeto direto:', result);
}
return {
success: result.success ?? true,
};
} catch (error: any) {
console.error('Erro ao deletar chat:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar chat',
status: error.status,
};
}
}
/**
* Busca todas as mensagens de um chat específico
*
* @param chatId - ID do chat
* @param userEmail - Email do usuário (opcional)
* @returns Promise com array de mensagens
*/
async getChatMessages(chatId: string, userEmail?: string): Promise<MessageRecord[]> {
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!chatId) {
throw {
success: false,
message: 'ID do chat não fornecido',
};
}
console.log('Buscando mensagens do chat:', {
chatId,
userEmail: email,
});
try {
const response = await apiService.get<MessageRecord[]>(
`${this.GET_CHAT_MESSAGES_ENDPOINT}/${email}/${chatId}`
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de mensagens
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'messages' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).messages)) {
return (response.data as any).messages;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de mensagens da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar mensagens:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar mensagens',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const chatService = new ChatService();
+420
View File
@@ -0,0 +1,420 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Tamanhos de imagem disponíveis
*/
export type ImageSize = '1024x1024' | '1024x1792' | '1792x1024';
/**
* Interface para a resposta da API de geração de imagens
*/
export interface ImageGenerationResponse {
success: boolean;
image_url?: string; // URL da imagem gerada (opcional quando há erro)
image_generation_id?: string; // ID da geração (opcional quando há erro)
message: string; // Descrição original ou mensagem de erro
code?: string; // Código de erro (ex: "server_error", "invalid_request")
}
/**
* Interface para uma imagem armazenada no banco de dados
*/
export interface ImageRecord {
id: string;
user_email: string;
estabelecimento_id: number;
description: string;
model: string;
image_url: string;
size: ImageSize;
cost_usd: string;
total_tokens: number;
input_tokens: number;
output_tokens: number;
created_at: string;
}
/**
* Interface para resposta paginada de imagens
*/
export interface GetImagesResponse {
images: ImageRecord[];
total: number;
page: number;
per_page: number;
total_pages: number;
}
/**
* Interface para os dados necessários para geração de imagem
*/
export interface ImageGenerationRequest {
description: string;
size: ImageSize;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Informações sobre cada tamanho de imagem disponível
*/
export const IMAGE_SIZE_OPTIONS = {
'1024x1024': {
label: 'Quadrado',
dimensions: '1024x1024',
aspectRatio: '1:1',
description: 'Ideal para avatares, ícones e posts em redes sociais',
},
'1024x1792': {
label: 'Retrato',
dimensions: '1024x1792',
aspectRatio: '9:16',
description: 'Perfeito para stories, wallpapers verticais e reels',
},
'1792x1024': {
label: 'Paisagem',
dimensions: '1792x1024',
aspectRatio: '16:9',
description: 'Ótimo para banners, capas e thumbnails de vídeos',
},
} as const;
/**
* Serviço de geração de imagens com IA
*/
class ImageGenerationService {
private readonly IMAGE_GENERATION_ENDPOINT = '/webhook/codex/image_generator';
private readonly GET_IMAGES_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_images';
private readonly DELETE_IMAGE_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_images';
/**
* Gera uma imagem a partir de uma descrição em texto
*
* @param request - Dados da requisição (descrição, tamanho, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async generateImage(request: ImageGenerationRequest): Promise<ImageGenerationResponse> {
const { description, size, userEmail, estabelecimentoId } = request;
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || parseInt(import.meta.env.VITE_ESTABELECIMENTO_ID) || 1;
// Valida a descrição
if (!description || description.trim().length === 0) {
throw {
success: false,
message: 'A descrição não pode estar vazia',
};
}
// Valida o tamanho
if (!this.isValidSize(size)) {
throw {
success: false,
message: `Tamanho inválido. Opções disponíveis: ${Object.keys(IMAGE_SIZE_OPTIONS).join(', ')}`,
};
}
// Log para debug (remover em produção se necessário)
console.log('Gerando imagem:', {
descriptionLength: description.length,
size,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
// Nota: O campo no body é "estabelecito_id" (com typo na API)
const response = await apiService.post<ImageGenerationResponse>(
this.IMAGE_GENERATION_ENDPOINT,
{
estabelecimento_id: estabId, // Mantém o typo da API original
user_email: email,
description: description,
size: size,
}
);
// Verifica se a resposta indica erro
if (!response.data.success) {
throw {
success: false,
message: response.data.message || 'Erro ao gerar imagem',
code: response.data.code,
};
}
return response.data;
} catch (error: any) {
// Trata erros específicos da API
console.error('Erro na geração de imagem:', error);
// Se o erro já tem a estrutura esperada (veio da validação acima), repassa
if (error.success === false && error.message) {
throw error;
}
// Se o erro veio da requisição HTTP, tenta extrair a resposta da API
if (error.response?.data) {
const apiError = error.response.data;
throw {
success: false,
message: apiError.message || 'Erro ao gerar imagem',
code: apiError.code,
};
}
// Erro genérico
throw {
success: false,
message: error.message || 'Erro ao gerar imagem. Tente novamente.',
status: error.status,
};
}
}
/**
* Valida se o tamanho selecionado é suportado
*
* @param size - Tamanho a ser validado
* @returns true se o tamanho é válido
*/
isValidSize(size: string): size is ImageSize {
return Object.keys(IMAGE_SIZE_OPTIONS).includes(size);
}
/**
* Obtém informações sobre um tamanho específico
*
* @param size - Tamanho da imagem
* @returns Informações do tamanho
*/
getSizeInfo(size: ImageSize) {
return IMAGE_SIZE_OPTIONS[size];
}
/**
* Lista todos os tamanhos disponíveis
*
* @returns Array com todas as opções de tamanho
*/
getAllSizes() {
return Object.entries(IMAGE_SIZE_OPTIONS).map(([key, info]) => ({
value: key as ImageSize,
...info,
}));
}
/**
* Valida a descrição para geração de imagem
*
* @param description - Descrição a ser validada
* @param minLength - Comprimento mínimo (padrão: 3 caracteres)
* @param maxLength - Comprimento máximo (padrão: 1000 caracteres)
* @returns Objeto com resultado da validação
*/
validateDescription(
description: string,
minLength: number = 3,
maxLength: number = 1000
): { valid: boolean; error?: string } {
if (!description || description.trim().length === 0) {
return {
valid: false,
error: 'A descrição não pode estar vazia',
};
}
if (description.trim().length < minLength) {
return {
valid: false,
error: `A descrição deve ter pelo menos ${minLength} caracteres`,
};
}
if (description.length > maxLength) {
return {
valid: false,
error: `A descrição é muito longa. Máximo: ${maxLength} caracteres`,
};
}
return { valid: true };
}
/**
* Faz download de uma imagem gerada
*
* @param imageUrl - URL da imagem
* @param filename - Nome do arquivo (opcional)
*/
async downloadImage(imageUrl: string, filename?: string): Promise<void> {
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `imagem_${Date.now()}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Erro ao baixar imagem:', error);
throw new Error('Não foi possível baixar a imagem');
}
}
/**
* Lista imagens do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com a resposta paginada
*/
async getImages(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<ImageRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando imagens:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<ImageRecord[]>(
`${this.GET_IMAGES_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de imagens
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'images' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).images)) {
return (response.data as any).images;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de imagens da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar imagens:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar imagens',
status: error.status,
};
}
}
/**
* Deleta uma imagem do banco de dados
*
* @param imageId - ID da imagem a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteImage(imageId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!imageId) {
throw {
success: false,
message: 'ID da imagem não fornecido',
};
}
console.log('Deletando imagem:', {
imageId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_IMAGE_ENDPOINT}/${email}/${imageId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API pode retornar um array com um objeto: [{"success":true}]
// ou diretamente um objeto: {"success":true}
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Imagem deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar imagem:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar imagem',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const imageGenerationService = new ImageGenerationService();
+33
View File
@@ -0,0 +1,33 @@
/**
* Exporta todos os serviços de API
*/
export { apiService } from './api';
export { transcriptionService } from './transcription';
export { audioGenerationService, VOICE_OPTIONS } from './audioGeneration';
export { imageGenerationService, IMAGE_SIZE_OPTIONS } from './imageGeneration';
export { personalAgent } from './personalAgent';
export { asanaService } from './asana';
export type { TranscriptionResponse, TranscriptionRequest } from './transcription';
export type { AudioGenerationResponse, AudioGenerationRequest, VoiceType } from './audioGeneration';
export type { ImageGenerationResponse, ImageGenerationRequest, ImageSize } from './imageGeneration';
export type {
UserProfile,
CreateUserRequest,
UpdateUserRequest,
FinancialIndicators,
ExpenseItem,
ExpensesResponse,
ExpensesFilters,
ExpenseCategory
} from './personalAgent';
export type {
AsanaWorkspace,
AsanaWorkspaceResponse,
AsanaWorkspacesResponse,
AsanaUser,
AsanaUserResponse,
AsanaUsersResponse,
AsanaIntegrationResponse
} from './asana';
export type { ApiResponse, UserConfig, ApiError } from './types';
+444
View File
@@ -0,0 +1,444 @@
import { GlobalFunctions } from '@/GlobalFunctions';
export interface UserProfile {
success: boolean;
id?: string;
nome: string;
email: string;
whatsapp: string;
followup: boolean;
message?: string;
}
export interface CreateUserRequest {
nome: string;
email: string;
whatsapp: string;
followup: boolean;
}
export interface UpdateUserRequest {
nome: string;
whatsapp: string;
followup: boolean;
}
export interface FinancialIndicators {
success: boolean;
id: string;
nome: string;
email: string;
total_despesas: string;
total_corporativo: string;
total_pessoal: string;
message?: string;
}
export interface ExpenseItem {
id: string;
data_hora: string;
descricao: string;
tipo: "pessoal" | "corporativo";
valor: string;
categoria_id: number;
categoria_nome: string;
usuario_nome: string;
usuario_email: string;
}
export interface ExpensesResponse {
success: boolean;
total_registros: number;
total_paginas: number;
per_page: number;
pagina_atual: number;
data: ExpenseItem[];
}
export interface ExpensesFilters {
page?: number;
per_page?: number;
descricao?: string;
categoria_id?: number;
data_inicial?: string;
data_final?: string;
}
export interface ExpenseCategory {
id: number;
nome: string;
descricao: string;
criado_em: string;
atualizado_em: string;
}
class PersonalAgent {
async getUserProfile(userEmail?: string): Promise<UserProfile> {
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.get<UserProfile>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/user/${email}`,
{ headers }
);
return response.data;
} catch (error: any) {
if (error.response?.data) {
return error.response.data;
}
throw {
success: false,
message: error.message || 'Erro ao buscar perfil do usuário',
status: error.response?.status,
};
}
}
async createUser(request: CreateUserRequest): Promise<UserProfile> {
if (!request.nome || request.nome.trim().length === 0) {
throw {
success: false,
message: 'Nome é obrigatório',
};
}
if (!request.email || request.email.trim().length === 0) {
throw {
success: false,
message: 'Email é obrigatório',
};
}
if (!request.whatsapp || request.whatsapp.trim().length === 0) {
throw {
success: false,
message: 'WhatsApp é obrigatório',
};
}
const whatsappNumbers = request.whatsapp.replace(/\D/g, '');
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
} else {
console.warn('API_KEY não configurada');
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.post<UserProfile>(
'https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/user',
{
nome: request.nome.trim(),
email: request.email.trim(),
whatsapp: whatsappNumbers,
followup: request.followup,
},
{ headers }
);
return response.data;
} catch (error: any) {
if (error.response?.data) {
throw error.response.data;
}
throw {
success: false,
message: error.message || 'Erro ao criar usuário',
status: error.response?.status,
};
}
}
async updateUser(userId: string, request: UpdateUserRequest): Promise<UserProfile> {
if (!userId || userId.trim().length === 0) {
throw {
success: false,
message: 'ID do usuário é obrigatório',
};
}
if (!request.nome || request.nome.trim().length === 0) {
throw {
success: false,
message: 'Nome é obrigatório',
};
}
if (!request.whatsapp || request.whatsapp.trim().length === 0) {
throw {
success: false,
message: 'WhatsApp é obrigatório',
};
}
const whatsappNumbers = request.whatsapp.replace(/\D/g, '');
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.post<UserProfile>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/user/${userId}`,
{
nome: request.nome.trim(),
whatsapp: whatsappNumbers,
followup: request.followup,
},
{ headers }
);
return response.data;
} catch (error: any) {
if (error.response?.data) {
throw error.response.data;
}
throw {
success: false,
message: error.message || 'Erro ao atualizar usuário',
status: error.response?.status,
};
}
}
async getFinancialIndicators(userEmail?: string): Promise<FinancialIndicators> {
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
if (!email) {
throw {
success: false,
message: 'Email do usuário é obrigatório',
};
}
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.get<FinancialIndicators>(
`https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/financas/indicadores/${email}`,
{ headers }
);
return response.data;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: FinancialIndicators } };
if (axiosError.response?.data) {
throw axiosError.response.data;
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao buscar indicadores financeiros',
status: error && typeof error === 'object' && 'response' in error
? (error as { response?: { status?: number } }).response?.status
: undefined,
};
}
}
async getExpenses(userEmail?: string, filters?: ExpensesFilters): Promise<ExpensesResponse> {
const email = userEmail || GlobalFunctions.getUsuarioLogado().email;
if (!email) {
throw {
success: false,
message: 'Email do usuário é obrigatório',
};
}
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Constrói query params
const params = new URLSearchParams();
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.per_page) params.append('per_page', filters.per_page.toString());
if (filters?.descricao) params.append('descricao', filters.descricao);
if (filters?.categoria_id) params.append('categoria_id', filters.categoria_id.toString());
if (filters?.data_inicial) params.append('data_inicial', filters.data_inicial);
if (filters?.data_final) params.append('data_final', filters.data_final);
const queryString = params.toString();
const url = `https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/2299acaf-70ee-47e3-a6fc-56dcc678d651/codex/agente-pessoal/financas/${email}${queryString ? `?${queryString}` : ''}`;
const response = await axios.get<ExpensesResponse[]>(url, { headers });
if (Array.isArray(response.data) && response.data.length > 0) {
const expensesResponse = response.data[0];
// Valida e limpa o array de dados, removendo objetos vazios
if (expensesResponse.data && Array.isArray(expensesResponse.data)) {
// Filtra objetos vazios (sem propriedades ou apenas com propriedades vazias)
expensesResponse.data = expensesResponse.data.filter((item) => {
// Verifica se o objeto tem pelo menos uma propriedade válida
return item && typeof item === 'object' && Object.keys(item).length > 0 && item.id;
});
// Se após filtrar não há dados, garante que data seja um array vazio
if (expensesResponse.data.length === 0) {
expensesResponse.data = [];
expensesResponse.total_registros = 0;
expensesResponse.total_paginas = 0;
}
} else {
// Se data não é um array válido, inicializa como array vazio
expensesResponse.data = [];
expensesResponse.total_registros = 0;
expensesResponse.total_paginas = 0;
}
return expensesResponse;
}
// Fallback caso a estrutura seja diferente - retorna resposta vazia
return {
success: true,
total_registros: 0,
total_paginas: 0,
per_page: filters?.per_page || 10,
pagina_atual: filters?.page || 1,
data: [],
};
} catch (error: unknown) {
// Retorna o erro da API se existir
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: ExpensesResponse } };
if (axiosError.response?.data) {
throw axiosError.response.data;
}
}
throw {
success: false,
message: error instanceof Error ? error.message : 'Erro ao buscar despesas',
status: error && typeof error === 'object' && 'response' in error
? (error as { response?: { status?: number } }).response?.status
: undefined,
};
}
}
async getCategories(): Promise<ExpenseCategory[]> {
try {
const axios = (await import('axios')).default;
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || '';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (apiKey) {
headers['apikey'] = apiKey;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await axios.get<ExpenseCategory[]>('https://prod-hgtx-intelligence-n8n.hgtx.com.br/webhook/codex/agente-pessoal/categorias', { headers });
// A API retorna um array de categorias
if (Array.isArray(response.data)) {
return response.data;
}
return [];
} catch (error: unknown) {
console.error('Erro ao buscar categorias:', error);
return [];
}
}
}
export const personalAgent = new PersonalAgent();
+272
View File
@@ -0,0 +1,272 @@
import { GlobalFunctions, TransferAreaProperties } from '@/GlobalFunctions';
import { apiService } from './api';
/**
* Interface para a resposta da API de transcrição
*/
export interface TranscriptionResponse {
success: boolean;
transcription_id: string;
audio_url: string;
message: string;
}
/**
* Interface para uma transcrição armazenada no banco de dados
*/
export interface TranscriptionRecord {
id: string;
user_email: string;
estabelecimento_id: number;
audio_file_name: string;
audio_duration_seconds: number;
transcription_text: string;
model: string;
audio_url: string;
cost_usd: string;
created_at: string;
}
/**
* Interface para os dados necessários para transcrição
*/
export interface TranscriptionRequest {
audioFile: File;
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Serviço de transcrição de áudio
*/
class TranscriptionService {
private readonly TRANSCRIPTION_ENDPOINT = '/webhook/codex/transcrever_audio';
private readonly GET_TRANSCRIPTIONS_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/get_transcrever_audio';
private readonly DELETE_TRANSCRIPTION_ENDPOINT = '/webhook/fd073ec0-81ab-4681-8acd-b0da71f6ad30/codex/delete_transcrever_audio';
/**
* Transcreve um arquivo de áudio
*
* @param request - Dados da requisição (arquivo, email, estabelecimento)
* @returns Promise com a resposta da API
*/
async transcribeAudio(request: TranscriptionRequest): Promise<TranscriptionResponse> {
const { audioFile, userEmail, estabelecimentoId } = request;
// Cria FormData para envio multipart
const formData = new FormData();
formData.append('data', audioFile);
// Usa valores do .env se não forem fornecidos
const email = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);//userEmail || import.meta.env.VITE_USER_EMAIL || '';
const estabId = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);//estabelecimentoId || import.meta.env.VITE_ESTABELECIMENTO_ID || '';
formData.append('user_email', email);
formData.append('estabelecimento_id', estabId.toString());
// Log para debug (remover em produção se necessário)
console.log('Enviando transcrição:', {
fileName: audioFile.name,
fileSize: audioFile.size,
fileType: audioFile.type,
userEmail: email,
estabelecimentoId: estabId,
});
try {
// Faz a requisição usando o serviço de API
const response = await apiService.postFormData<TranscriptionResponse>(
this.TRANSCRIPTION_ENDPOINT,
formData
);
return response.data;
} catch (error: any) {
// Trata erros específicos
console.error('Erro na transcrição:', error);
throw {
success: false,
message: error.message || 'Erro ao transcrever áudio',
status: error.status,
};
}
}
/**
* Valida se o arquivo de áudio é suportado
*
* @param file - Arquivo a ser validado
* @param maxSizeMB - Tamanho máximo em MB (padrão: 25MB)
* @returns Objeto com resultado da validação
*/
validateAudioFile(file: File, maxSizeMB: number = 25): { valid: boolean; error?: string } {
const SUPPORTED_FORMATS = ['flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'mpga', 'oga', 'ogg', 'wav', 'webm'];
const MAX_FILE_SIZE = maxSizeMB * 1024 * 1024;
// Valida tamanho
if (file.size > MAX_FILE_SIZE) {
return {
valid: false,
error: `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`,
};
}
// Valida formato
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (!fileExtension || !SUPPORTED_FORMATS.includes(fileExtension)) {
return {
valid: false,
error: `Formato não suportado. Formatos aceitos: ${SUPPORTED_FORMATS.join(', ')}`,
};
}
return { valid: true };
}
/**
* Lista transcrições do banco de dados com paginação
*
* @param userEmail - Email do usuário
* @param page - Número da página (padrão: 1)
* @param perPage - Quantidade de itens por página (padrão: 10)
* @returns Promise com o array de transcrições
*/
async getTranscriptions(
userEmail?: string,
page: number = 1,
perPage: number = 10
): Promise<TranscriptionRecord[]> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
console.log('Buscando transcrições:', {
userEmail: email,
page,
perPage,
});
try {
// Faz a requisição GET com parâmetros na URL e query
const response = await apiService.get<TranscriptionRecord[]>(
`${this.GET_TRANSCRIPTIONS_ENDPOINT}/${email}`,
{
params: {
page: page.toString(),
per_page: perPage.toString(),
},
}
);
console.log('Resposta completa da API:', response);
console.log('response.data:', response.data);
console.log('É array?:', Array.isArray(response.data));
// A API retorna diretamente o array de transcrições
// Garante que sempre retorna um array
if (Array.isArray(response.data)) {
return response.data;
} else if (response.data && typeof response.data === 'object') {
// Se a resposta for um objeto com uma propriedade 'transcriptions' ou similar
console.warn('API retornou objeto em vez de array:', response.data);
// Tenta encontrar o array dentro do objeto
if (Array.isArray((response.data as any).transcriptions)) {
return (response.data as any).transcriptions;
} else if (Array.isArray((response.data as any).data)) {
return (response.data as any).data;
}
}
// Se não conseguir extrair array, retorna vazio
console.warn('Não foi possível extrair array de transcrições da resposta');
return [];
} catch (error: any) {
console.error('Erro ao buscar transcrições:', error);
throw {
success: false,
message: error.message || 'Erro ao buscar transcrições',
status: error.status,
};
}
}
/**
* Deleta uma transcrição do banco de dados
*
* @param transcriptionId - ID da transcrição a ser deletada
* @param userEmail - Email do usuário (opcional)
* @returns Promise com sucesso ou erro
*/
async deleteTranscription(transcriptionId: string, userEmail?: string): Promise<{ success: boolean; message?: string }> {
// Usa valores do GlobalFunctions se não forem fornecidos
const email = userEmail || GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (!email) {
throw {
success: false,
message: 'Email do usuário não fornecido',
};
}
if (!transcriptionId) {
throw {
success: false,
message: 'ID da transcrição não fornecido',
};
}
console.log('Deletando transcrição:', {
transcriptionId,
userEmail: email,
});
try {
// Faz a requisição DELETE com parâmetros na URL
const response = await apiService.delete<{ success: boolean; message?: string } | Array<{ success: boolean; message?: string }>>(
`${this.DELETE_TRANSCRIPTION_ENDPOINT}/${email}/${transcriptionId}`
);
console.log('Resposta completa do DELETE:', response);
console.log('response.data:', response.data);
// A API retorna um array com um objeto: [{"success":true}]
let result: { success: boolean; message?: string };
if (Array.isArray(response.data)) {
// Se for array, pega o primeiro elemento
result = response.data[0];
console.log('API retornou array, usando primeiro elemento:', result);
} else {
// Se for objeto direto
result = response.data;
console.log('API retornou objeto direto:', result);
}
// Garante que tem a estrutura mínima
return {
success: result.success ?? true,
message: result.message || 'Transcrição deletada com sucesso',
};
} catch (error: any) {
console.error('Erro ao deletar transcrição:', error);
throw {
success: false,
message: error.message || 'Erro ao deletar transcrição',
status: error.status,
};
}
}
}
// Exporta instância única (Singleton)
export const transcriptionService = new TranscriptionService();
+30
View File
@@ -0,0 +1,30 @@
/**
* Tipos compartilhados para as APIs
*/
/**
* Resposta padrão de sucesso/erro da API
*/
export interface ApiResponse<T = any> {
success: boolean;
message?: string;
data?: T;
}
/**
* Configuração de usuário para requisições
*/
export interface UserConfig {
userEmail?: string;
estabelecimentoId?: number;
}
/**
* Resposta de erro da API
*/
export interface ApiError {
success: false;
message: string;
status?: number;
data?: any;
}
+14
View File
@@ -1 +1,15 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_API_KEY: string
readonly VITE_USER_EMAIL: string
readonly VITE_ESTABELECIMENTO_ID: string
readonly VITE_BASE_URL_OPEN_CODEX_APIs: string
readonly VITE_BASE_URL_HGTX_CORE_API: string
readonly VITE_BASE_URL_HGTX_CORE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+8
View File
@@ -34,6 +34,14 @@ export default {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
+8
View File
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => ({
server: {
host: "::",
port: 8080,
proxy: {
'/api/intelligence': {
target: 'https://prod-hgtx-intelligence-n8n.hgtx.com.br',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/intelligence/, ''),
secure: true,
},
},
},
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
resolve: {