Compare commits
28 Commits
9d925d1ae3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 39032ea621 | |||
| 71f9c9ab86 | |||
| d75d80e9c7 | |||
| 189a2a8f2c | |||
| 50aca767d7 | |||
| 76cbd80eef | |||
| ee7204982c | |||
| a28b780718 | |||
| 0fa382ef0e | |||
| c2aa536660 | |||
| c4b281abd5 | |||
| 45ea53b364 | |||
| 9f75feb69a | |||
| a85465e332 | |||
| 8a9096470f | |||
| c82c2166f2 | |||
| c8c2ca8c5b | |||
| 8b5c940976 | |||
| 235fb538e1 | |||
| 27820254e7 | |||
| e9f0165006 | |||
| c88342369f | |||
| 18285851ac | |||
| b1b37f5cf1 | |||
| b81e1cf47d | |||
| 9ab3530a80 | |||
| c37c854b07 | |||
| b0a1cfdab5 |
@@ -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
|
||||||
@@ -12,6 +12,11 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="pt-br">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
<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: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: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:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@lovable_dev" />
|
<meta name="twitter:site" content="@hgtx" />
|
||||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
<meta name="twitter:image" content="/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+268
@@ -37,12 +37,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -3324,6 +3326,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.21",
|
"version": "10.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||||
@@ -3362,6 +3370,17 @@
|
|||||||
"postcss": "^8.1.0"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"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": "^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": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -3583,6 +3615,18 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -3792,6 +3836,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-node-es": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||||
@@ -3820,6 +3873,20 @@
|
|||||||
"csstype": "^3.0.2"
|
"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": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
@@ -3867,6 +3934,51 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -4253,6 +4365,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
|
||||||
@@ -4269,6 +4401,22 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@@ -4306,6 +4454,30 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-nonce": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||||
@@ -4315,6 +4487,19 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "10.4.5",
|
"version": "10.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||||
@@ -4384,6 +4569,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/graphemer": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||||
@@ -4401,6 +4598,33 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -4605,6 +4829,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -5174,6 +5406,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5196,6 +5437,27 @@
|
|||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -5631,6 +5893,12 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@@ -40,12 +40,14 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"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
@@ -2,10 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { ThemeProvider } from "next-themes";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import NotFound from "./pages/NotFound";
|
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();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -17,9 +21,18 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" 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="*" element={<NotFound />} />
|
||||||
|
<Route path="/404" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -28,3 +41,13 @@ const App = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|
||||||
|
const HGTXLogin = () =>{
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||||
|
});
|
||||||
|
|
||||||
|
return(<></>);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, UserCog } from "lucide-react";
|
import { ChevronDown, UserCog, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 {
|
interface ChatHeaderProps {
|
||||||
showModelSelector?: boolean;
|
showModelSelector?: boolean;
|
||||||
@@ -27,20 +31,32 @@ interface ChatHeaderProps {
|
|||||||
|
|
||||||
export const ChatHeader = ({
|
export const ChatHeader = ({
|
||||||
showModelSelector = false,
|
showModelSelector = false,
|
||||||
selectedModel = "ChatGPT 4.1",
|
selectedModel = "GPT-4o",
|
||||||
onModelChange,
|
onModelChange,
|
||||||
systemPrompt = "Você é um assistente útil e prestativo.",
|
systemPrompt = DEFAULT_SYSTEM_PROMPT,
|
||||||
onSystemPromptChange
|
onSystemPromptChange
|
||||||
}: ChatHeaderProps) => {
|
}: ChatHeaderProps) => {
|
||||||
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
|
const [isPersonalityOpen, setIsPersonalityOpen] = useState(false);
|
||||||
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
|
const [tempSystemPrompt, setTempSystemPrompt] = useState(systemPrompt);
|
||||||
|
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||||
|
|
||||||
const models = [
|
// Carrega modelos da API ao montar o componente
|
||||||
"ChatGPT 4.1",
|
useEffect(() => {
|
||||||
"ChatGPT 5",
|
const loadModels = async () => {
|
||||||
"Claude Sonnet 4.5",
|
setIsLoadingModels(true);
|
||||||
"Claude Opus 4.1",
|
try {
|
||||||
];
|
const modelsFromAPI = await fetchModelsFromDatabase();
|
||||||
|
setModels(modelsFromAPI);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar modelos:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingModels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadModels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSavePersonality = () => {
|
const handleSavePersonality = () => {
|
||||||
if (onSystemPromptChange) {
|
if (onSystemPromptChange) {
|
||||||
@@ -50,8 +66,8 @@ export const ChatHeader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-6 py-4 flex items-center justify-between">
|
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
||||||
<span className="text-sm text-muted-foreground">Online</span>
|
<span className="text-sm text-muted-foreground">Online</span>
|
||||||
@@ -60,19 +76,37 @@ export const ChatHeader = ({
|
|||||||
{showModelSelector && onModelChange && (
|
{showModelSelector && onModelChange && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="gap-2 glass-effect">
|
<Button
|
||||||
<span className="font-medium">{selectedModel}</span>
|
variant="outline"
|
||||||
<ChevronDown className="w-4 h-4" />
|
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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="start" className="glass-effect bg-popover border-border z-50">
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model}
|
key={model.id}
|
||||||
className="cursor-pointer"
|
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>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -84,11 +118,11 @@ export const ChatHeader = ({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 glass-effect"
|
className="gap-1 md:gap-2 glass-effect text-xs md:text-sm"
|
||||||
onClick={() => setTempSystemPrompt(systemPrompt)}
|
onClick={() => setTempSystemPrompt(systemPrompt)}
|
||||||
>
|
>
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="font-medium">Personalidade</span>
|
<span className="hidden md:inline font-medium">Personalidade</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -120,7 +154,7 @@ export const ChatHeader = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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"}
|
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+36
-19
@@ -1,16 +1,17 @@
|
|||||||
import { useState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
activeTab: "chat" | "images" | "audio" | "bots" | "agent";
|
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||||
onTabChange: (tab: "chat" | "images" | "audio" | "bots" | "agent") => void;
|
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) => {
|
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
@@ -18,16 +19,17 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
|
{ id: "chat" as TabType, label: "Bate-papo", icon: MessageSquare },
|
||||||
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
{ id: "images" as TabType, label: "Imagens", icon: Image },
|
||||||
{ id: "audio" as TabType, label: "Áudio", icon: Mic },
|
{ id: "transcription" as TabType, label: "Transcrição de Áudio", icon: AudioLines },
|
||||||
{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Sparkles },
|
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||||
|
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
{!isSidebarCollapsed ? (
|
{!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 */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
|
<div className="p-6 border-b border-sidebar-border flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -74,14 +76,14 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
<Button onClick={GlobalFunctions.handleNavigateBack} variant="ghost" className="w-full justify-start gap-2 text-sidebar-foreground">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Collapsed Header with Expand Button */}
|
||||||
<div className="p-3 border-b border-sidebar-border space-y-2">
|
<div className="p-3 border-b border-sidebar-border space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -127,12 +129,7 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
{/* Theme & Settings Icons */}
|
{/* Theme & Settings Icons */}
|
||||||
<div className="p-3 border-t border-sidebar-border space-y-2">
|
<div className="p-3 border-t border-sidebar-border space-y-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="w-full text-sidebar-foreground" title="Voltar">
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="w-full text-sidebar-foreground"
|
|
||||||
title="Voltar"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,9 +137,29 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden w-full">{children}</main>
|
||||||
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +1,81 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Search, FileText } from "lucide-react";
|
import { Search, FileText } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
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 {
|
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) => {
|
export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
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()) {
|
if (!searchTerm.trim()) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = mockOpinionsDatabase.filter(
|
const timer = setTimeout(() => {
|
||||||
(opinion) =>
|
if (currentPage === 1) {
|
||||||
opinion.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
handleSearch();
|
||||||
opinion.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
} else {
|
||||||
opinion.category?.toLowerCase().includes(searchTerm.toLowerCase())
|
setCurrentPage(1); // Volta para primeira página ao buscar
|
||||||
);
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
setSearchResults(results);
|
return () => clearTimeout(timer);
|
||||||
};
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(searchResults.length / itemsPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
@@ -76,21 +85,28 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
<Input
|
<Input
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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()}
|
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" />
|
<Search className="w-4 h-4" />
|
||||||
Buscar
|
{isLoading ? 'Buscando...' : 'Buscar'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Base com {mockOpinionsDatabase.length} pareceres disponíveis
|
{searchResults.length} {searchResults.length === 1 ? 'parecer encontrado' : 'pareceres encontrados'}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
{searchResults.length > 0 ? (
|
{isLoading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12">
|
||||||
|
<p>Buscando pareceres...</p>
|
||||||
|
</div>
|
||||||
|
) : searchResults.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{searchResults.map((opinion) => (
|
{searchResults.map((opinion) => (
|
||||||
<div
|
<div
|
||||||
@@ -101,13 +117,13 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<FileText className="w-5 h-5 text-primary mt-1" />
|
<FileText className="w-5 h-5 text-primary mt-1" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-foreground">{opinion.title}</h3>
|
<h3 className="font-semibold text-foreground">{opinion.titulo}</h3>
|
||||||
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
|
<div className="flex gap-4 mt-2 text-sm text-muted-foreground">
|
||||||
<span>{opinion.category}</span>
|
<span>{opinion.categoria || '-'}</span>
|
||||||
<span>{new Date(opinion.createdAt).toLocaleDateString('pt-BR')}</span>
|
<span>{new Date(opinion.created_at).toLocaleDateString('pt-BR')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||||
{opinion.content}
|
{opinion.instrucoes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +140,67 @@ export const AgentSearch = ({ onSelectOpinion }: AgentSearchProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,74 +1,530 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { AgentSidebar } from "./AgentSidebar";
|
import { Plus, Search, Eye, Trash2, ArrowUpDown, Download, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
import { AgentChat } from "./AgentChat";
|
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 { AgentSearch } from "./AgentSearch";
|
||||||
|
import { agentService, OpinionRecord } from "@/services/agent";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export interface LegalOpinion {
|
type SortField = "titulo" | "created_at" | "categoria";
|
||||||
id: string;
|
type SortOrder = "asc" | "desc";
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: Date;
|
|
||||||
category?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AgentView = () => {
|
export const AgentView = () => {
|
||||||
const [opinions, setOpinions] = useState<LegalOpinion[]>([]);
|
const [opinions, setOpinions] = useState<OpinionRecord[]>([]);
|
||||||
const [selectedOpinion, setSelectedOpinion] = useState<LegalOpinion | null>(null);
|
const [pendingOpinions, setPendingOpinions] = useState<OpinionRecord[]>([]); // Registros temporários sendo processados
|
||||||
const [isCreatingNew, setIsCreatingNew] = useState(false);
|
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 [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 = () => {
|
// Carrega pareceres da API
|
||||||
setIsCreatingNew(true);
|
const loadOpinions = useCallback(async () => {
|
||||||
setSelectedOpinion(null);
|
setIsLoading(true);
|
||||||
setShowSearch(false);
|
try {
|
||||||
};
|
const data = await agentService.getOpinions({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: itemsPerPage,
|
||||||
|
search: searchTerm,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSelectOpinion = (opinion: LegalOpinion) => {
|
// Adiciona status aos pareceres da API
|
||||||
setSelectedOpinion(opinion);
|
const opinionsWithStatus = data.map(opinion => ({
|
||||||
setIsCreatingNew(false);
|
...opinion,
|
||||||
setShowSearch(false);
|
status: (opinion.file_url || opinion.file_url_melhoria) ? 'concluido' : 'processando' as const,
|
||||||
};
|
}));
|
||||||
|
|
||||||
const handleSearch = () => {
|
setOpinions(opinionsWithStatus);
|
||||||
setShowSearch(true);
|
|
||||||
setIsCreatingNew(false);
|
|
||||||
setSelectedOpinion(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpinionCreated = (newOpinion: LegalOpinion) => {
|
// Remove registros temporários que agora estão na API
|
||||||
setOpinions([newOpinion, ...opinions]);
|
setPendingOpinions(prev =>
|
||||||
setSelectedOpinion(newOpinion);
|
prev.filter(pending =>
|
||||||
setIsCreatingNew(false);
|
!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) => {
|
// Polling automático para atualizar status dos pareceres
|
||||||
setOpinions(opinions.filter(op => op.id !== id));
|
useEffect(() => {
|
||||||
if (selectedOpinion?.id === id) {
|
// Carrega imediatamente
|
||||||
setSelectedOpinion(null);
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex flex-col h-full">
|
||||||
<AgentSidebar
|
<div className="p-6 border-b border-border">
|
||||||
opinions={opinions}
|
<Button variant="outline" onClick={() => setShowSearch(false)}>
|
||||||
onCreateNew={handleCreateNew}
|
Voltar para Pareceres
|
||||||
onSelectOpinion={handleSelectOpinion}
|
</Button>
|
||||||
onDeleteOpinion={handleDeleteOpinion}
|
</div>
|
||||||
onSearch={handleSearch}
|
<AgentSearch onSelectOpinion={handleSelectFromSearch} />
|
||||||
selectedOpinionId={selectedOpinion?.id}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||||
|
<div className="p-3 md:p-6 border-b border-border">
|
||||||
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||||
|
<h1 className="text-xl md:text-3xl font-bold text-foreground">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-1">
|
<div className="flex items-center gap-2 justify-between md:justify-start">
|
||||||
{showSearch ? (
|
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
|
||||||
<AgentSearch onSelectOpinion={handleSelectOpinion} />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<AgentChat
|
sortedOpinions.map((opinion) => (
|
||||||
selectedOpinion={selectedOpinion}
|
<TableRow key={opinion.id}>
|
||||||
isCreatingNew={isCreatingNew}
|
<TableCell className="font-medium text-xs md:text-sm">{opinion.titulo}</TableCell>
|
||||||
onOpinionCreated={handleOpinionCreated}
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -270,22 +270,22 @@ export const AudioView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-3 md:p-6">
|
||||||
<Tabs defaultValue="transcription" className="space-y-6">
|
<Tabs defaultValue="transcription" className="space-y-4 md:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-4 glass-effect">
|
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4 glass-effect text-xs md:text-sm">
|
||||||
<TabsTrigger value="transcription">
|
<TabsTrigger value="transcription" className="text-xs md:text-sm">
|
||||||
Transcrição
|
Transcrição
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="generation">Geração de Voz</TabsTrigger>
|
<TabsTrigger value="generation" className="text-xs md:text-sm">Geração</TabsTrigger>
|
||||||
<TabsTrigger value="transcription-history">
|
<TabsTrigger value="transcription-history" className="text-xs md:text-sm">
|
||||||
Histórico Transcrições
|
Hist. Trans.
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audio-history">
|
<TabsTrigger value="audio-history" className="text-xs md:text-sm">
|
||||||
Histórico Áudios
|
Hist. Áud.
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -95,19 +95,19 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0">
|
||||||
<div className="border-b border-border p-6">
|
<div className="border-b border-border p-3 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold flex items-center gap-2">
|
<h2 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||||
<Bot className="w-6 h-6" />
|
<Bot className="w-5 h-5 md:w-6 md:h-6" />
|
||||||
Gerenciador de Bots
|
Gerenciador de Bots
|
||||||
</h2>
|
</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>
|
</div>
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button className="w-full md:w-auto text-sm">
|
||||||
<Bot className="w-4 h-4 mr-2" />
|
<Bot className="w-4 h-4 mr-2" />
|
||||||
Criar Bot
|
Criar Bot
|
||||||
</Button>
|
</Button>
|
||||||
@@ -167,7 +167,7 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
{bots.length === 0 ? (
|
{bots.length === 0 ? (
|
||||||
<Card className="max-w-2xl mx-auto mt-12">
|
<Card className="max-w-2xl mx-auto mt-12">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -190,45 +190,48 @@ export const BotView = ({ onStartChat }: BotViewProps) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Nome</TableHead>
|
<TableHead className="text-xs md:text-sm">Nome</TableHead>
|
||||||
<TableHead>Modelo</TableHead>
|
<TableHead className="hidden md:table-cell text-sm">Modelo</TableHead>
|
||||||
<TableHead className="text-right">Ações</TableHead>
|
<TableHead className="text-right text-xs md:text-sm">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{currentBots.map((bot) => (
|
{currentBots.map((bot) => (
|
||||||
<TableRow key={bot.id}>
|
<TableRow key={bot.id}>
|
||||||
<TableCell className="font-medium">{bot.name}</TableCell>
|
<TableCell className="font-medium text-xs md:text-sm">{bot.name}</TableCell>
|
||||||
<TableCell>{bot.model}</TableCell>
|
<TableCell className="hidden md:table-cell text-sm">{bot.model}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-1 justify-end flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onStartChat(bot)}
|
onClick={() => onStartChat(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Conversar
|
<span className="hidden md:inline">Conversar</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openEditDialog(bot)}
|
onClick={() => openEditDialog(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
<Edit className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Editar
|
<span className="hidden md:inline">Editar</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openDeleteDialog(bot)}
|
onClick={() => openDeleteDialog(bot)}
|
||||||
|
className="text-xs md:text-sm"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-3 h-3 md:w-4 md:h-4 md:mr-2" />
|
||||||
Excluir
|
<span className="hidden md:inline">Excluir</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { chatService } from "@/services/chat";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSendMessage: (message: string, files?: File[]) => void;
|
onSendMessage: (message: string, files?: File[]) => void;
|
||||||
@@ -67,21 +68,54 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
const newFiles = [...attachedFiles, ...files];
|
||||||
|
|
||||||
// Validate file size (max 10MB per file)
|
// Valida quantidade máxima de anexos (5)
|
||||||
const validFiles = files.filter((file) => {
|
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) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: "Arquivo muito grande",
|
title: "Arquivo muito grande",
|
||||||
description: `${file.name} excede o limite de 10MB`,
|
description: `${file.name} excede o limite de 10MB`,
|
||||||
variant: "destructive",
|
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) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
@@ -92,19 +126,19 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-4">
|
<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-3">
|
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3">
|
||||||
{/* Attached Files Preview */}
|
{/* Attached Files Preview */}
|
||||||
{attachedFiles.length > 0 && (
|
{attachedFiles.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-1 md:gap-2">
|
||||||
{attachedFiles.map((file, index) => (
|
{attachedFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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" />
|
<Paperclip className="w-3 h-3 text-primary" />
|
||||||
<span className="font-medium">{file.name}</span>
|
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="hidden md:inline text-xs text-muted-foreground">
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -121,12 +155,12 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Input and Buttons */}
|
{/* 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">
|
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||||
{/* Resize Handle */}
|
{/* Resize Handle - Hidden on mobile */}
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
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' : ''
|
isDragging ? 'bg-primary/20' : ''
|
||||||
}`}
|
}`}
|
||||||
title="Arraste para cima para aumentar"
|
title="Arraste para cima para aumentar"
|
||||||
@@ -135,51 +169,52 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
{/* 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
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Digite sua mensagem..."
|
placeholder="Digite sua mensagem..."
|
||||||
style={{ height: `${textareaHeight}px` }}
|
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"
|
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1 md:gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept=".pdf,.txt,.doc,.docx,.xls,.xlsx,.csv,.png,.jpg,.jpeg,.gif,.webp"
|
accept={chatService.getAllowedFileExtensions()}
|
||||||
|
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
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()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Anexar arquivo"
|
title="Anexar arquivo"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
size="icon"
|
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}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
||||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. 10MB por arquivo
|
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,22 +30,22 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
isAssistant ? "justify-start" : "justify-end"
|
||||||
}`}
|
}`}
|
||||||
onMouseEnter={() => setShowActions(true)}
|
onMouseEnter={() => setShowActions(true)}
|
||||||
onMouseLeave={() => setShowActions(false)}
|
onMouseLeave={() => setShowActions(false)}
|
||||||
>
|
>
|
||||||
{isAssistant && (
|
{isAssistant && (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-1 md: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">
|
<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-5 h-5 text-primary-foreground" />
|
<Bot className="w-4 h-4 md:w-5 md:h-5 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{model && (
|
{model && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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}
|
{model}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -53,7 +53,7 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
</div>
|
</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 && attachments.length > 0 && (
|
{attachments && attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
@@ -73,13 +73,13 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<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
|
isAssistant
|
||||||
? "bg-card border border-border"
|
? "bg-card border border-border"
|
||||||
: "bg-gradient-to-br from-primary to-secondary text-primary-foreground"
|
: "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}
|
{content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,8 +100,8 @@ export const ChatMessage = ({ role, content, model, attachments }: ChatMessagePr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAssistant && (
|
{!isAssistant && (
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-accent/20 border border-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-5 h-5 text-accent" />
|
<User className="w-4 h-4 md:w-5 md:h-5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+298
-174
@@ -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 { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -30,115 +34,159 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 {
|
interface ChatSidebarProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onNewChat: () => 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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
const [isEditFolderOpen, setIsEditFolderOpen] = useState(false);
|
|
||||||
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
const [isDeleteFolderOpen, setIsDeleteFolderOpen] = useState(false);
|
||||||
|
const [isDeleteChatOpen, setIsDeleteChatOpen] = useState(false);
|
||||||
|
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState("");
|
const [newFolderName, setNewFolderName] = useState("");
|
||||||
const [editingFolder, setEditingFolder] = useState<ChatFolder | null>(null);
|
const [renamingFolder, setRenamingFolder] = useState<FolderRecord | null>(null);
|
||||||
const [deletingFolder, setDeletingFolder] = useState<ChatFolder | null>(null);
|
const [renamedFolderName, setRenamedFolderName] = useState("");
|
||||||
const [selectedConversation, setSelectedConversation] = useState<string | null>("1");
|
const [deletingFolder, setDeletingFolder] = useState<FolderRecord | null>(null);
|
||||||
|
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [folders, setFolders] = useState<ChatFolder[]>([
|
// Estado carregado do banco de dados via chatService
|
||||||
{ id: "work", name: "Trabalho" },
|
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||||
{ id: "personal", name: "Pessoal" },
|
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||||
]);
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([
|
// Carrega chats e pastas do banco de dados quando o componente monta
|
||||||
{
|
useEffect(() => {
|
||||||
id: "1",
|
loadChatsAndFolders();
|
||||||
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",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(["work", "personal"]));
|
// Escuta evento customizado para recarregar quando chat é atualizado
|
||||||
|
const handleChatUpdate = () => {
|
||||||
const handleCreateFolder = () => {
|
loadChatsAndFolders();
|
||||||
if (newFolderName.trim()) {
|
|
||||||
const newFolder: ChatFolder = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: newFolderName,
|
|
||||||
};
|
};
|
||||||
setFolders([...folders, newFolder]);
|
|
||||||
|
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()) {
|
||||||
|
try {
|
||||||
|
await chatService.createFolder(newFolderName);
|
||||||
|
await loadChatsAndFolders();
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
setIsCreateFolderOpen(false);
|
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 = () => {
|
const handleDeleteFolder = async () => {
|
||||||
if (editingFolder && newFolderName.trim()) {
|
|
||||||
setFolders(
|
|
||||||
folders.map((f) =>
|
|
||||||
f.id === editingFolder.id ? { ...f, name: newFolderName } : f
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setNewFolderName("");
|
|
||||||
setEditingFolder(null);
|
|
||||||
setIsEditFolderOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFolder = () => {
|
|
||||||
if (deletingFolder) {
|
if (deletingFolder) {
|
||||||
// Remove folder and move conversations back to "no folder"
|
try {
|
||||||
setConversations(
|
await chatService.deleteFolder(deletingFolder.id);
|
||||||
conversations.map((conv) =>
|
await loadChatsAndFolders();
|
||||||
conv.folderId === deletingFolder.id ? { ...conv, folderId: undefined } : conv
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setFolders(folders.filter((f) => f.id !== deletingFolder.id));
|
|
||||||
setDeletingFolder(null);
|
setDeletingFolder(null);
|
||||||
setIsDeleteFolderOpen(false);
|
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) => {
|
const openDeleteFolder = (folder: FolderRecord) => {
|
||||||
setEditingFolder(folder);
|
|
||||||
setNewFolderName(folder.name);
|
|
||||||
setIsEditFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openDeleteFolder = (folder: ChatFolder) => {
|
|
||||||
setDeletingFolder(folder);
|
setDeletingFolder(folder);
|
||||||
setIsDeleteFolderOpen(true);
|
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 toggleFolder = (folderId: string) => {
|
||||||
const newExpanded = new Set(expandedFolders);
|
const newExpanded = new Set(expandedFolders);
|
||||||
if (newExpanded.has(folderId)) {
|
if (newExpanded.has(folderId)) {
|
||||||
@@ -149,37 +197,79 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
setExpandedFolders(newExpanded);
|
setExpandedFolders(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveToFolder = (conversationId: string, folderId: string) => {
|
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||||
setConversations(
|
try {
|
||||||
conversations.map((conv) =>
|
await chatService.moveChatToFolder(chatId, folderId);
|
||||||
conv.id === conversationId ? { ...conv, folderId } : conv
|
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) => {
|
const handleDeleteChat = async () => {
|
||||||
setConversations(
|
if (deletingChat) {
|
||||||
conversations.map((conv) =>
|
try {
|
||||||
conv.id === conversationId ? { ...conv, folderId: undefined } : conv
|
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(
|
const openDeleteChat = (chat: ChatRecord) => {
|
||||||
(conv) =>
|
setDeletingChat(chat);
|
||||||
conv.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
setIsDeleteChatOpen(true);
|
||||||
conv.lastMessage.toLowerCase().includes(searchQuery.toLowerCase())
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const conversationsWithoutFolder = filteredConversations.filter((c) => !c.folderId);
|
const handleSelectChat = (chat: ChatRecord) => {
|
||||||
const conversationsByFolder = folders.reduce((acc, folder) => {
|
if (onSelectChat) {
|
||||||
acc[folder.id] = filteredConversations.filter((c) => c.folderId === folder.id);
|
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;
|
return acc;
|
||||||
}, {} as Record<string, Conversation[]>);
|
}, {} as Record<string, ChatRecord[]>);
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -193,7 +283,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Header with collapse button */}
|
||||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
||||||
@@ -261,33 +351,34 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit Folder Dialog */}
|
{/* Rename Folder Dialog */}
|
||||||
<Dialog open={isEditFolderOpen} onOpenChange={setIsEditFolderOpen}>
|
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Editar Pasta</DialogTitle>
|
<DialogTitle>Renomear Pasta</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Renomeie sua pasta de conversas.
|
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-folder-name">Nome da Pasta</Label>
|
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-folder-name"
|
id="rename-folder-name"
|
||||||
value={newFolderName}
|
value={renamedFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||||
placeholder="Digite o novo nome..."
|
placeholder="Ex: Projetos, Estudos..."
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleEditFolder()}
|
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsEditFolderOpen(false)}>
|
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEditFolder} disabled={!newFolderName.trim()}>
|
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||||
Salvar
|
Renomear
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -299,7 +390,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -315,6 +406,12 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
{/* Conversations List */}
|
{/* Conversations List */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
|
{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 */}
|
||||||
{folders.map((folder) => (
|
{folders.map((folder) => (
|
||||||
<div key={folder.id} className="space-y-1">
|
<div key={folder.id} className="space-y-1">
|
||||||
@@ -330,7 +427,7 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
{folder.name}
|
{folder.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{conversationsByFolder[folder.id]?.length || 0}
|
{chatsByFolder[folder.id]?.length || 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -347,10 +444,10 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => openEditFolder(folder)}
|
onClick={() => openRenameFolder(folder)}
|
||||||
>
|
>
|
||||||
<Edit className="w-3 h-3" />
|
<Edit className="w-3 h-3" />
|
||||||
Editar Nome
|
Renomear Pasta
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2 text-destructive"
|
className="gap-2 text-destructive"
|
||||||
@@ -363,17 +460,17 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedFolders.has(folder.id) && conversationsByFolder[folder.id]?.length > 0 && (
|
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||||
<div className="ml-6 space-y-1">
|
<div className="ml-6 space-y-1">
|
||||||
{conversationsByFolder[folder.id].map((conv) => (
|
{chatsByFolder[folder.id].map((chat) => (
|
||||||
<ConversationItem
|
<ChatItem
|
||||||
key={conv.id}
|
key={chat.id}
|
||||||
conversation={conv}
|
chat={chat}
|
||||||
isSelected={selectedConversation === conv.id}
|
isSelected={currentChatId === chat.id}
|
||||||
onSelect={() => setSelectedConversation(conv.id)}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onRemoveFromFolder={removeFromFolder}
|
onDelete={() => openDeleteChat(chat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,65 +478,103 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat }: ChatSi
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Conversations without folder */}
|
{/* Chats without folder */}
|
||||||
{conversationsWithoutFolder.length > 0 && (
|
{chatsWithoutFolder.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Sem Pasta
|
Sem Pasta
|
||||||
</div>
|
</div>
|
||||||
{conversationsWithoutFolder.map((conv) => (
|
{chatsWithoutFolder.map((chat) => (
|
||||||
<ConversationItem
|
<ChatItem
|
||||||
key={conv.id}
|
key={chat.id}
|
||||||
conversation={conv}
|
chat={chat}
|
||||||
isSelected={selectedConversation === conv.id}
|
isSelected={currentChatId === chat.id}
|
||||||
onSelect={() => setSelectedConversation(conv.id)}
|
onSelect={() => handleSelectChat(chat)}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onRemoveFromFolder={removeFromFolder}
|
onDelete={() => openDeleteChat(chat)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConversationItemProps {
|
interface ChatItemProps {
|
||||||
conversation: Conversation;
|
chat: ChatRecord;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
folders: ChatFolder[];
|
folders: FolderRecord[];
|
||||||
onMoveToFolder: (conversationId: string, folderId: string) => void;
|
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||||
onRemoveFromFolder: (conversationId: string) => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConversationItem = ({
|
const ChatItem = ({
|
||||||
conversation,
|
chat,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
folders,
|
folders,
|
||||||
onMoveToFolder,
|
onMoveToFolder,
|
||||||
onRemoveFromFolder,
|
onDelete,
|
||||||
}: ConversationItemProps) => {
|
}: ChatItemProps) => {
|
||||||
|
// Formata timestamp relativo
|
||||||
|
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: ptBR,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
isSelected
|
||||||
? "bg-sidebar-accent cyber-border"
|
? "bg-sidebar-accent cyber-border"
|
||||||
: "hover:bg-muted/50"
|
: "hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-4 h-4 mt-0.5 text-primary flex-shrink-0" />
|
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 max-w-[180px]">
|
||||||
<p className="text-sm font-medium truncate">{conversation.title}</p>
|
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
{chat.title}
|
||||||
{conversation.lastMessage}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
||||||
{conversation.timestamp}
|
{timeAgo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -448,39 +583,28 @@ const ConversationItem = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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" />
|
<MoreVertical className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||||
<DropdownMenuItem className="gap-2">
|
{/* Opções para mover para pastas */}
|
||||||
<Edit className="w-3 h-3" />
|
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
||||||
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) => (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => onMoveToFolder(conversation.id, folder.id)}
|
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="w-3 h-3" />
|
||||||
Mover para {folder.name}
|
Mover para {folder.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<DropdownMenuItem className="gap-2 text-destructive">
|
<DropdownMenuItem
|
||||||
|
className="gap-2 text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
Excluir
|
Excluir
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { ChatMessage } from "./ChatMessage";
|
import { ChatMessage } from "./ChatMessage";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatSidebar } from "./ChatSidebar";
|
import { ChatSidebar } from "./ChatSidebar";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,24 +24,64 @@ interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatView = () => {
|
export const ChatView = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState("ChatGPT 4.1");
|
const [selectedModel, setSelectedModel] = useState("GPT-4o");
|
||||||
const [currentChatId, setCurrentChatId] = useState("1");
|
// Inicia com "0" - será atualizado com o chat_id real após primeira resposta da API
|
||||||
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
|
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[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||||
model: "ChatGPT 4.1",
|
model: "GPT-4o",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 handleNewChat = () => {
|
||||||
const newChatId = Date.now().toString();
|
// Reseta para "0" - novo chat sempre começa com chat_id "0"
|
||||||
setCurrentChatId(newChatId);
|
setCurrentChatId("0");
|
||||||
|
|
||||||
|
// Reseta a personalidade para o texto padrão
|
||||||
|
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||||
|
|
||||||
setMessages([
|
setMessages([
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "1",
|
||||||
@@ -44,9 +90,67 @@ export const ChatView = () => {
|
|||||||
model: selectedModel,
|
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 = {
|
const newMessage: Message = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -61,31 +165,87 @@ export const ChatView = () => {
|
|||||||
setMessages((prev) => [...prev, newMessage]);
|
setMessages((prev) => [...prev, newMessage]);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Simulate AI response
|
try {
|
||||||
setTimeout(() => {
|
// 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 = {
|
const aiResponse: Message = {
|
||||||
id: (Date.now() + 1).toString(),
|
id: (Date.now() + 1).toString(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content:
|
content: response.response,
|
||||||
`Resposta com base na personalidade: "${systemPrompt}". Esta é uma resposta simulada que considera as instruções de sistema definidas.`,
|
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, aiResponse]);
|
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: "Desculpe, ocorreu um erro ao processar sua mensagem. Por favor, tente novamente.",
|
||||||
|
model: selectedModel,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, 1500);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||||
{/* Chat Sidebar */}
|
{/* Chat Sidebar - Hidden on mobile */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
isCollapsed={isChatSidebarCollapsed}
|
isCollapsed={isChatSidebarCollapsed}
|
||||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||||
onNewChat={handleNewChat}
|
onNewChat={handleNewChat}
|
||||||
|
onSelectChat={handleLoadChat}
|
||||||
|
currentChatId={currentChatId}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
{/* Main Chat Area */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col w-full">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
showModelSelector={true}
|
showModelSelector={true}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
@@ -95,7 +255,7 @@ export const ChatView = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<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) => (
|
{messages.map((message) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message.id}
|
key={message.id}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChatHeader } from "@/components/ChatHeader";
|
import { ChatHeader } from "@/components/ChatHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -13,82 +13,226 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
interface GeneratedImage {
|
import {
|
||||||
id: string;
|
imageGenerationService,
|
||||||
url: string;
|
IMAGE_SIZE_OPTIONS,
|
||||||
prompt: string;
|
ImageSize,
|
||||||
size: string;
|
ImageRecord
|
||||||
timestamp: string;
|
} from "@/services/imageGeneration";
|
||||||
}
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
export const ImageView = () => {
|
export const ImageView = () => {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [selectedSize, setSelectedSize] = useState("1024x1024");
|
const [selectedSize, setSelectedSize] = useState<ImageSize>("1024x1024");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [images, setImages] = useState<GeneratedImage[]>([
|
const [images, setImages] = useState<ImageRecord[]>([]);
|
||||||
{
|
const [lastGeneratedImage, setLastGeneratedImage] = useState<ImageRecord | null>(null);
|
||||||
id: "1",
|
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
prompt: "Paisagem futurista com cidades voadoras",
|
const [perPage, setPerPage] = useState(10);
|
||||||
size: "1024x1024",
|
const { toast } = useToast();
|
||||||
timestamp: "Há 2 horas",
|
|
||||||
},
|
// Carrega imagens do banco de dados ao montar o componente
|
||||||
{
|
const loadImages = async (page: number = currentPage, limit: number = perPage) => {
|
||||||
id: "2",
|
setIsLoadingImages(true);
|
||||||
url: "https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=512&h=512&fit=crop",
|
try {
|
||||||
prompt: "Robô humanoide em estilo cyberpunk",
|
const fetchedImages = await imageGenerationService.getImages(undefined, page, limit);
|
||||||
size: "1024x1536",
|
|
||||||
timestamp: "Há 5 horas",
|
// 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);
|
setIsGenerating(true);
|
||||||
// Simulate image generation
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
const newImage: GeneratedImage = {
|
// Chama o serviço de geração de imagem
|
||||||
id: Date.now().toString(),
|
const response = await imageGenerationService.generateImage({
|
||||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=512&h=512&fit=crop",
|
description: prompt,
|
||||||
prompt: prompt,
|
|
||||||
size: selectedSize,
|
size: selectedSize,
|
||||||
timestamp: "Agora",
|
});
|
||||||
|
|
||||||
|
// 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(),
|
||||||
};
|
};
|
||||||
setImages([newImage, ...images]);
|
|
||||||
setIsGenerating(false);
|
// Salva a última imagem gerada para exibição
|
||||||
|
setLastGeneratedImage(newGeneratedImage);
|
||||||
|
|
||||||
|
// Limpa o campo de descrição após sucesso
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
}, 3000);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
setImages(images.filter((img) => img.id !== id));
|
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) =>
|
const handleDownload = async (image: ImageRecord) => {
|
||||||
img.prompt.toLowerCase().includes(searchQuery.toLowerCase())
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full pb-16 md:pb-0">
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-5xl mx-auto p-6">
|
<div className="max-w-5xl mx-auto p-3 md:p-6">
|
||||||
<Tabs defaultValue="generate" className="space-y-6">
|
<Tabs defaultValue="generate" className="space-y-4 md:space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-2 glass-effect">
|
<TabsList className="grid w-full grid-cols-2 glass-effect">
|
||||||
<TabsTrigger value="generate">Gerar Imagem</TabsTrigger>
|
<TabsTrigger value="generate" className="text-xs md:text-sm">Gerar Imagem</TabsTrigger>
|
||||||
<TabsTrigger value="history">
|
<TabsTrigger value="history" className="text-xs md:text-sm">
|
||||||
<Clock className="w-4 h-4 mr-2" />
|
<Clock className="w-3 h-3 md:w-4 md:h-4 mr-1 md:mr-2" />
|
||||||
Histórico
|
<span className="hidden md:inline">Histórico</span>
|
||||||
|
<span className="md:hidden">Hist.</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Generate Tab */}
|
{/* Generate Tab */}
|
||||||
<TabsContent value="generate" className="space-y-6">
|
<TabsContent value="generate" className="space-y-4 md:space-y-6">
|
||||||
<div className="glass-effect rounded-xl p-6 space-y-4">
|
<div className="glass-effect rounded-xl p-4 md:p-6 space-y-3 md:space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-sm font-medium text-foreground">
|
||||||
Descreva a imagem
|
Descreva a imagem
|
||||||
@@ -101,19 +245,19 @@ export const ImageView = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex-1 space-y-2">
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-sm font-medium text-foreground">
|
||||||
Tamanho
|
Tamanho
|
||||||
</label>
|
</label>
|
||||||
<Select value={selectedSize} onValueChange={setSelectedSize}>
|
<Select value={selectedSize} onValueChange={(value) => setSelectedSize(value as ImageSize)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="glass-effect bg-popover border-border z-50">
|
<SelectContent className="glass-effect bg-popover border-border z-50">
|
||||||
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
|
<SelectItem value="1024x1024">1024x1024 (Quadrado)</SelectItem>
|
||||||
<SelectItem value="1024x1536">1024x1536 (Retrato)</SelectItem>
|
<SelectItem value="1024x1792">1024x1792 (Retrato)</SelectItem>
|
||||||
<SelectItem value="1536x1024">1536x1024 (Paisagem)</SelectItem>
|
<SelectItem value="1792x1024">1792x1024 (Paisagem)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,12 +293,13 @@ export const ImageView = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Images Preview */}
|
{/* Recent Images Preview */}
|
||||||
{!isGenerating && images.length > 0 && (
|
{!isGenerating && lastGeneratedImage && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
|
<h3 className="text-lg font-semibold mb-4">Última Geração</h3>
|
||||||
<ImageCard
|
<ImageCard
|
||||||
image={images[0]}
|
image={lastGeneratedImage}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -162,8 +307,9 @@ export const ImageView = () => {
|
|||||||
|
|
||||||
{/* History Tab */}
|
{/* History Tab */}
|
||||||
<TabsContent value="history" className="space-y-4">
|
<TabsContent value="history" className="space-y-4">
|
||||||
{/* Search */}
|
{/* Search and Filters */}
|
||||||
<div className="relative">
|
<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" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -172,18 +318,82 @@ export const ImageView = () => {
|
|||||||
className="pl-9"
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Images Grid */}
|
{/* Images Grid */}
|
||||||
{filteredImages.length > 0 ? (
|
{filteredImages.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{filteredImages.map((image) => (
|
{filteredImages.map((image) => (
|
||||||
<ImageCard
|
<ImageCard
|
||||||
key={image.id}
|
key={image.id}
|
||||||
image={image}
|
image={image}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onDownload={handleDownload}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="glass-effect rounded-xl p-12 text-center">
|
||||||
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
<Clock className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
@@ -192,6 +402,8 @@ export const ImageView = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,31 +414,96 @@ export const ImageView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ImageCardProps {
|
interface ImageCardProps {
|
||||||
image: GeneratedImage;
|
image: ImageRecord;
|
||||||
onDelete: (id: string) => void;
|
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 `Há ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
|
||||||
|
if (hours < 24) return `Há ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
|
||||||
|
return `Há ${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 (
|
return (
|
||||||
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
|
<div className="group glass-effect rounded-xl overflow-hidden animate-fade-in">
|
||||||
<div className="relative aspect-square">
|
<div className="relative aspect-square">
|
||||||
|
{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
|
<img
|
||||||
src={image.url}
|
src={image.image_url}
|
||||||
alt={image.prompt}
|
alt={image.description}
|
||||||
className="w-full h-full object-cover"
|
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 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">
|
<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">
|
<div className="flex items-center justify-between text-xs text-white/70">
|
||||||
<span>{image.size}</span>
|
<span>{IMAGE_SIZE_OPTIONS[image.size as ImageSize]?.label || image.size}</span>
|
||||||
<span>{image.timestamp}</span>
|
<span>{getRelativeTime(image.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1 gap-1"
|
className="flex-1 gap-1"
|
||||||
|
onClick={() => onDownload(image)}
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="w-3 h-3" />
|
||||||
Baixar
|
Baixar
|
||||||
|
|||||||
@@ -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
@@ -31,6 +31,12 @@
|
|||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--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%;
|
--border: 214 32% 91%;
|
||||||
--input: 214 32% 91%;
|
--input: 214 32% 91%;
|
||||||
--ring: 190 100% 45%;
|
--ring: 190 100% 45%;
|
||||||
@@ -84,6 +90,12 @@
|
|||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 200 100% 95%;
|
--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%;
|
--border: 222 30% 18%;
|
||||||
--input: 222 30% 18%;
|
--input: 222 30% 18%;
|
||||||
--ring: 190 100% 50%;
|
--ring: 190 100% 50%;
|
||||||
@@ -137,7 +149,7 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.glass-effect {
|
.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 {
|
.cyber-glow {
|
||||||
@@ -152,4 +164,63 @@
|
|||||||
.gradient-text {
|
.gradient-text {
|
||||||
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent;
|
@apply bg-gradient-to-r from-primary to-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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
+22
-4
@@ -2,10 +2,14 @@ import { useState } from "react";
|
|||||||
import { Layout } from "@/components/Layout";
|
import { Layout } from "@/components/Layout";
|
||||||
import { ChatView } from "@/components/chat/ChatView";
|
import { ChatView } from "@/components/chat/ChatView";
|
||||||
import { ImageView } from "@/components/images/ImageView";
|
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 { BotView } from "@/components/bots/BotView";
|
||||||
import { BotChat } from "@/components/bots/BotChat";
|
import { BotChat } from "@/components/bots/BotChat";
|
||||||
import { AgentView } from "@/components/agent/AgentView";
|
import { AgentView } from "@/components/agent/AgentView";
|
||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import React from "react";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
|
|
||||||
interface Bot {
|
interface Bot {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,7 +19,7 @@ interface Bot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Index = () => {
|
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 [activeBotChat, setActiveBotChat] = useState<Bot | null>(null);
|
||||||
|
|
||||||
const handleStartBotChat = (bot: Bot) => {
|
const handleStartBotChat = (bot: Bot) => {
|
||||||
@@ -26,11 +30,19 @@ const Index = () => {
|
|||||||
setActiveBotChat(null);
|
setActiveBotChat(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<Layout activeTab={activeView} onTabChange={setActiveView}>
|
||||||
{activeView === "chat" && <ChatView />}
|
{activeView === "chat" && <ChatView />}
|
||||||
{activeView === "images" && <ImageView />}
|
{activeView === "images" && <ImageView />}
|
||||||
{activeView === "audio" && <AudioView />}
|
{activeView === "transcription" && <TranscriptionView />}
|
||||||
|
{activeView === "generation" && <GenerationView />}
|
||||||
{activeView === "bots" && (
|
{activeView === "bots" && (
|
||||||
activeBotChat ? (
|
activeBotChat ? (
|
||||||
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
<BotChat bot={activeBotChat} onBack={handleBackFromBotChat} />
|
||||||
@@ -40,6 +52,12 @@ const Index = () => {
|
|||||||
)}
|
)}
|
||||||
{activeView === "agent" && <AgentView />}
|
{activeView === "agent" && <AgentView />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
} />
|
||||||
|
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to={`/404`} replace />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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..." />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+14
@@ -1 +1,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export default {
|
|||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
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: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: "hsl(var(--muted))",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export default defineConfig(({ mode }) => ({
|
|||||||
server: {
|
server: {
|
||||||
host: "::",
|
host: "::",
|
||||||
port: 8080,
|
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),
|
plugins: [react(), mode === "development" && componentTagger()].filter(Boolean),
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user