diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8248282..08cd56a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,11 +1,18 @@ -# ─── Dev override — NO docker compose build required ───────────────────────── +# ─── Dev override — modo desenvolvimento com HMR ───────────────────────────── # -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d +# Uso: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build # -# What changes in dev: -# - Backend: Runs node server.js with direct hot mount of backend/ -# - Frontend: Runs vite dev server on port 3013 with Hot Module Replacement (HMR) +# O que muda em relação à produção: +# - Backend: monta ./backend ao vivo (node server.js), preservando o +# node_modules da imagem (node:20-slim) — código reflete sem rebuild. +# - Frontend: builda o stage `builder` (node:20-alpine, que TEM vite + +# node_modules no arch musl correto) e roda o vite dev server na 3013 com +# Hot Module Replacement. O nginx (default.conf) já faz proxy de / → :3013 +# com headers de Upgrade, então o WS de HMR passa. +# +# ⚠️ Este box serve APENAS dev.scoreodonto.com. A produção (scoreodonto.com) +# roda em outro servidor — flipar esta stack para dev NÃO afeta produção. # ───────────────────────────────────────────────────────────────────────────── services: @@ -15,13 +22,20 @@ services: - NODE_ENV=development volumes: - "/home/deploy/scoreodonto-sessions:/app/sessions" - - "./backend:/app" + - "/home/deploy/scoreodonto-media:/app/media" + - "./backend:/app" # source ao vivo (server.js + newwhats/ do túnel) + - "/app/node_modules" # preserva node_modules da imagem (arch correto) + - "./plugins:/app/plugins" scoreodonto-frontend: + build: + context: "." + dockerfile: frontend/Dockerfile + target: builder # node:20-alpine com vite + node_modules (musl) command: ["npx", "vite", "--port", "3013", "--host", "0.0.0.0"] environment: - - PORT=3013 - NODE_ENV=development + - DEV_PUBLIC_HOST=dev.scoreodonto.com # libera host + roteia HMR por wss:443 (vite.config) volumes: - - "./frontend:/app" - - /app/node_modules + - "./frontend:/app/frontend" # source ao vivo (HMR) + - "/app/frontend/node_modules" # preserva node_modules da imagem (musl) diff --git a/frontend/App.tsx b/frontend/App.tsx index 9075657..b53101b 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -13,6 +13,9 @@ import { useAppVersion } from './buildInfo.ts'; import { Dashboard } from './views/Dashboard.tsx'; import { LeadsView } from './views/LeadsView.tsx'; import { WaInboxView } from './views/WaInboxView.tsx'; +import { SessionsView } from './views/newwhats/SessionsView.tsx'; +import { InboxView } from './views/newwhats/InboxView.tsx'; +import { SecretariaView } from './views/newwhats/SecretariaView.tsx'; import { WaSecretariaView } from './views/WaSecretariaView.tsx'; import { PublicContactForm } from './views/PublicContactForm.tsx'; import { PatientsView } from './views/PatientsView.tsx'; @@ -89,6 +92,7 @@ export type ViewKey = | 'plugins' | 'rx-radiografias' | 'wa-inbox' + | 'wa-sessions' | 'wa-secretaria' | 'salas' | 'minhas-salas' @@ -149,6 +153,7 @@ const ROUTE_MAP: Record = { '/plugins': 'plugins', '/rx-radiografias': 'rx-radiografias', '/wa-inbox': 'wa-inbox', + '/wa-sessions': 'wa-sessions', '/wa-secretaria': 'wa-secretaria', '/salas': 'salas', '/minhas-salas': 'minhas-salas', @@ -214,6 +219,7 @@ const VIEW_TO_ROUTE: Record = { plugins: '/plugins', 'rx-radiografias': '/rx-radiografias', 'wa-inbox': '/wa-inbox', + 'wa-sessions': '/wa-sessions', 'wa-secretaria': '/wa-secretaria', salas: '/salas', 'minhas-salas': '/minhas-salas', @@ -325,7 +331,7 @@ const App: React.FC = () => { if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas'); // NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo // (a CONFIG do plugin é exclusiva do superadmin via view 'plugins'). - if (view === 'wa-inbox' || view === 'wa-secretaria') return isPluginActive('newwhats'); + if (view === 'wa-inbox' || view === 'wa-sessions' || view === 'wa-secretaria') return isPluginActive('newwhats'); // Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações. if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view); // Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações. @@ -517,8 +523,9 @@ const App: React.FC = () => { case 'contratos': return ; case 'plugins': return ; case 'rx-radiografias': return ; - case 'wa-inbox': return ; - case 'wa-secretaria': return ; + case 'wa-inbox': return ; + case 'wa-sessions': return ; + case 'wa-secretaria': return ; case 'salas': return ; case 'minhas-salas': return ; case 'alugar-salas': return ; @@ -554,7 +561,9 @@ const App: React.FC = () => { } }; - const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView); + // Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do + // scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair. + const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-sessions', 'wa-secretaria'].includes(currentView); return ( <> diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 5382c52..09e227e 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { Users, Calendar as CalendarIcon, LayoutDashboard, - DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot + DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone } from 'lucide-react'; import { ToothIcon } from './ToothIcon.tsx'; import { HybridBackend } from '../services/backend.ts'; @@ -41,6 +41,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => // ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin). if (isPluginActive('newwhats')) { pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' }); + pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' }); pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' }); } // Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de diff --git a/frontend/index.css b/frontend/index.css index 4b4734e..58e2cdf 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -1,5 +1,46 @@ @import "tailwindcss"; +/* ── Tema do motor NewWhats (plugin newwhats) — tokens Tailwind v4 ──────────── */ +/* Portados do tailwind.config.js do motor para as páginas inbox/sessions/ */ +/* secretária renderizarem idênticas ao motor. */ +@theme { + --color-brand-50: #eff6ff; + --color-brand-100: #dbeafe; + --color-brand-200: #bfdbfe; + --color-brand-300: #93c5fd; + --color-brand-400: #60a5fa; + --color-brand-500: #3b82f6; + --color-brand-600: #2563eb; + --color-brand-700: #1d4ed8; + --color-brand-800: #1e40af; + --color-brand-900: #1e3a8a; + --color-brand-950: #172554; + + --color-surface: #070b14; + --color-surface-raised: #0c1220; + --color-surface-overlay: #111827; + + --radius-4xl: 2rem; + + --shadow-glow-brand: 0 0 24px rgba(59, 130, 246, 0.15); + --shadow-glow-sm: 0 0 12px rgba(59, 130, 246, 0.1); + --shadow-premium: 0 8px 32px rgba(0, 0, 0, 0.4); + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + + --animate-shimmer: shimmer 3s linear infinite; + --animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-float: float 6s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + /* ScoreOdonto Base Styles */ body { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9bc9093..f62c938 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,9 @@ "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.90.21", "date-fns": "^4.1.0", + "framer-motion": "^12.42.2", + "immer": "^11.1.8", + "jspdf": "^2.5.2", "lucide-react": "^0.563.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -734,15 +737,6 @@ "node": ">=18" } }, - "node_modules/@fullcalendar/core": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", - "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", - "peer": true, - "dependencies": { - "preact": "~10.12.1" - } - }, "node_modules/@fullcalendar/daygrid": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", @@ -1522,11 +1516,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "dependencies": { "csstype": "^3.2.2" } @@ -1565,6 +1565,17 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -1601,6 +1612,15 @@ "postcss": "^8.1.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -1646,6 +1666,17 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -1666,12 +1697,42 @@ } ] }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/css-box-model": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", @@ -1680,11 +1741,20 @@ "tiny-invariant": "^1.0.6" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true + "dev": true }, "node_modules/date-fns": { "version": "4.1.0", @@ -1721,6 +1791,12 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "optional": true + }, "node_modules/electron-to-chromium": { "version": "1.5.355", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz", @@ -1807,6 +1883,11 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1820,6 +1901,32 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1849,6 +1956,28 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1888,6 +2017,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2163,6 +2309,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2193,6 +2352,12 @@ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2245,14 +2410,13 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, - "node_modules/preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" } }, "node_modules/raf-schd": { @@ -2315,6 +2479,21 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -2382,6 +2561,24 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -2401,6 +2598,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -2422,6 +2628,11 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2479,6 +2690,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c6abe7..f99eaa6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.90.21", "date-fns": "^4.1.0", + "framer-motion": "^12.42.2", + "immer": "^11.1.8", "jspdf": "^2.5.2", "lucide-react": "^0.563.0", "react": "^19.2.4", diff --git a/frontend/views/AdminViews.tsx b/frontend/views/AdminViews.tsx index 66968d6..3b46d0e 100644 --- a/frontend/views/AdminViews.tsx +++ b/frontend/views/AdminViews.tsx @@ -110,6 +110,7 @@ export const DentistasView: React.FC = () => { const [isHoursModalOpen, setIsHoursModalOpen] = useState(false); const [selectedDentist, setSelectedDentist] = useState(null); const [connectedAccounts, setConnectedAccounts] = useState([]); + const [setores, setSetores] = useState<{ id: string; nome: string }[]>([]); const toast = useToast(); const confirm = useConfirm(); @@ -125,6 +126,13 @@ export const DentistasView: React.FC = () => { useEffect(() => { fetchGoogleStatus(); + // Setores da clínica (para lotar o dentista num setor → isolamento da agenda). + (async () => { + try { + const r = await HybridBackend.getSetores(activeWorkspace?.id || ''); + setSetores(r?.setores || []); + } catch { /* clínica sem setores → seletor não aparece */ } + })(); }, []); useEffect(() => { @@ -261,6 +269,17 @@ export const DentistasView: React.FC = () => {
+ {/* Setor do dentista → isola a agenda dele para a equipe do setor. + Só aparece se a clínica usa setores. Vazio = geral (visível a todos). */} + {setores.length > 0 && ( +
+ + +
+ )} {/* Especialidades que o dentista atende (várias) */}
diff --git a/frontend/views/WaInboxView.tsx b/frontend/views/WaInboxView.tsx index f7fa80d..ed12d45 100644 --- a/frontend/views/WaInboxView.tsx +++ b/frontend/views/WaInboxView.tsx @@ -20,9 +20,42 @@ async function nw(path: string, opts: RequestInit = {}) { return res.json(); } +// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou +// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia +// Authorization no handshake de WebSocket. +function streamUrl(token: string) { + const base = API.startsWith('http') ? API : `${window.location.origin}${API}`; + const u = new URL(`${base}/nw/v1/stream`); + u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; + u.searchParams.set('token', token); + return u.toString(); +} + interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number } interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string } +// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox. +function normalizeWsMsg(m: any): Msg { + return { + id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: m?.body ?? m?.text, + from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0, + direction: m?.fromMe ? 'out' : 'in', + type: m?.type, + timestamp: m?.timestamp, + }; +} + +// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread +// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo. +function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] { + const i = list.findIndex((c) => String(c.id) === String(chatId)); + if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh + const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` }; + if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1; + return [updated, ...list.filter((_, idx) => idx !== i)]; +} + export const WaInboxView: React.FC = () => { const [sessions, setSessions] = useState([]); const [session, setSession] = useState(''); @@ -32,7 +65,59 @@ export const WaInboxView: React.FC = () => { const [text, setText] = useState(''); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); + const [wsUp, setWsUp] = useState(false); + const [qr, setQr] = useState(null); const endRef = useRef(null); + // Ref com a conversa aberta para o handler do WS ler sempre o valor atual + // (evita closure velha dentro do onmessage). + const activeRef = useRef(null); + useEffect(() => { activeRef.current = active; }, [active]); + + // Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch. + useEffect(() => { + const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); + if (!token) return; + let ws: WebSocket | null = null; + let retry: ReturnType | null = null; + let closed = false; + + const connect = () => { + try { ws = new WebSocket(streamUrl(token)); } catch { return; } + ws.onopen = () => setWsUp(true); + ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } }; + ws.onclose = () => { + setWsUp(false); + if (!closed) retry = setTimeout(connect, 3000); // reconexão simples + }; + ws.onmessage = (ev) => { + let env: any; + try { env = JSON.parse(ev.data); } catch { return; } + const { event, data } = env || {}; + if (event === 'message.new') { + const norm = normalizeWsMsg(data?.message); + const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id); + if (isActive) { + setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]); + setTimeout(() => endRef.current?.scrollIntoView(), 50); + } + setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive)); + } else if (event === 'session.status') { + setSessions((prev) => prev.map((s) => + String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s)); + if (data?.status === 'connected') setQr(null); + } else if (event === 'session.qr') { + setQr(data?.qrBase64 || null); + } + }; + }; + + connect(); + return () => { + closed = true; + if (retry) clearTimeout(retry); + try { ws?.close(); } catch { /* ignore */ } + }; + }, []); useEffect(() => { nw('/sessions').then((s) => { @@ -54,6 +139,7 @@ export const WaInboxView: React.FC = () => { const openChat = async (c: Chat) => { setActive(c); setMsgs([]); + setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x)); try { const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`); setMsgs(Array.isArray(m) ? m : (m.messages ?? [])); @@ -79,10 +165,20 @@ export const WaInboxView: React.FC = () => { {/* Lista de conversas */}
+
+ + {wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'} +
+ {qr && ( +
+
Escaneie para conectar
+ QR Code +
+ )}
{loading &&
Carregando…
} diff --git a/frontend/views/newwhats/InboxView.tsx b/frontend/views/newwhats/InboxView.tsx new file mode 100644 index 0000000..e0813b1 --- /dev/null +++ b/frontend/views/newwhats/InboxView.tsx @@ -0,0 +1,473 @@ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Search, + MoreVertical, + User, + MessageSquare, + ChevronDown, + RefreshCw, + MessageSquarePlus, + RotateCw, + ArrowLeft, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { format } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; +import type { NewWhatsChat } from './types/inboxTypes'; +import { getAvatarProxyUrl, formatPhone } from './utils/inboxUtils'; +import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge'; +import { useInstanceSelector } from './hooks/inbox/useInstanceSelector'; +import { useChatFilter } from './hooks/inbox/useChatFilter'; +import { useChatWindow } from './hooks/inbox/useChatWindow'; +import { useMessageInput } from './hooks/inbox/useMessageInput'; +import { nw } from './services/nwClient'; +import { useProtocolPanel } from './hooks/useProtocolPanel'; +import ChatSidebar from './components/ChatSidebar'; +import ThinSidebar, { TabType } from './components/ThinSidebar'; +import InboxHeader from './components/InboxHeader'; +import MessageArea from './components/MessageArea'; +import SettingsPanel from './components/SettingsPanel'; +import InputBar from './components/InputBar'; +import NewConversationModal from './components/NewConversationModal'; +import ContactProfile from './components/ContactProfile'; +import ProtocolPanel from './components/ProtocolPanel'; + +type Chat = NewWhatsChat; + +export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void }) { + + // ── Seleção de instância (hook dedicado) ───────────────────────────────── + const { + instances, + activeInstance, + mappedActiveInstance, + connectedInstances, + isConnected, + dropdownRef, + selectInstance, + refreshInstances, + handleConnect: handleConnectInstance, + } = useInstanceSelector() + + // ── Inbox bridge (chats + mensagens) ────────────────────────────────────── + const { + chats: chatsStore, + selectedChat, + messages, + loadingChats: loading, + loadingMessages, + presenceByJid, + handleSelectChat: handleSelectChatBridge, + handleSendText, + handleLoadMoreHistory, + handleMenuAction, + } = useNewInboxBridge() + + // ── Filtros de chat (hook dedicado) ─────────────────────────────────────── + const { + searchTerm, + activeFilter, + chatScope, + chatsByScope, + isFavoriteChat, + setSearchTerm, + setActiveFilter, + setChatScope, + toggleFavorite, + } = useChatFilter(chatsStore) + + // ── Chat window (hook dedicado) ─────────────────────────────────────────── + const { + scrollRef, + isTyping, + replyingTo, + reactionPickerFor, + hasMoreHistory, + loadingMore, + isProfileOpen, + isSyncingAvatar, + isChatMenuOpen, + setReplyingTo, + setIsProfileOpen, + setIsChatMenuOpen, + handleTyping, + handleScroll, + handleSelectChat: onChatWindowSelect, + handleReactToMessage, + handleSendMedia, + handleSendAudio, + handleSyncAvatar, + toggleReactionPicker, + } = useChatWindow({ + selectedChat, + activeInstanceId: activeInstance?.instance ?? null, + onLoadMoreHistory: handleLoadMoreHistory, + }) + + // ── Mensagem de entrada (hook dedicado) ─────────────────────────────────── + const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({ + selectedChat, + replyingTo, + onClearReply: () => setReplyingTo(null), + onSendText: handleSendText, + }) + + // ── Painel de Protocolo ─────────────────────────────────────────────────── + const [isProtocolOpen, setIsProtocolOpen] = useState(false) + const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null) + + // ── Estado local de UI ──────────────────────────────────────────────────── + const [activeTab, setActiveTab] = useState('chats') + const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false) + const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false) + const [drafts] = useState>({}) + const [isMobile, setIsMobile] = useState(false) + const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false) + + // Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em + // navegação client-side (router.push). Detectado por: + // 1. performance.getEntriesByType('navigation')[0].type === 'reload' + // 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão) + // sessionStorage sobrevive a navegações client-side mas é zerado em nova aba. + const [pageReady, setPageReady] = useState(() => { + if (typeof window === 'undefined') return false + const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined + const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1) + if (isHardReload) return false + return sessionStorage.getItem('nw:inbox-loaded') === '1' + }) + const prevLoadingRef = useRef(false) + const readyTimerRef = useRef | null>(null) + useEffect(() => { + if (prevLoadingRef.current && !loading) { + readyTimerRef.current = setTimeout(() => { + setPageReady(true) + sessionStorage.setItem('nw:inbox-loaded', '1') + }, 400) + } + prevLoadingRef.current = loading + return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) } + }, [loading]) + + const optionsRef = useRef(null) + const initialized = useRef(false) + + // ── Setup mobile + click-outside ───────────────────────────────────────── + useEffect(() => { + if (initialized.current) return + initialized.current = true + const resize = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', resize) + resize() + const click = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false) + if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false) + } + document.addEventListener('mousedown', click) + return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) } + }, []) + + // ── Handlers ───────────────────────────────────────────────────────────── + + const handleSelectChat = useCallback((chat: Chat | null) => { + handleSelectChatBridge(chat) + onChatWindowSelect() + setIsProtocolOpen(false) + }, [handleSelectChatBridge, onChatWindowSelect]) + + const handleChatMenuAction = useCallback((action: string, chat: Chat) => { + handleMenuAction(action, chat, toggleFavorite) + }, [handleMenuAction, toggleFavorite]) + + + const handleFetchChats = useCallback(async () => { + await refreshInstances() + setIsOptionsMenuOpen(false) + }, [refreshInstances]) + + // Voltar: se há chat aberto, fecha-o; senão, página anterior + const handleBack = useCallback(() => { + if (selectedChat) { + handleSelectChat(null) + } else { + onNavigate?.('dashboard') + } + }, [selectedChat, handleSelectChat, onNavigate]) + + // ESC fecha chat ou vai para página anterior + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleBack() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [handleBack]) + + // ── Render ──────────────────────────────────────────────────────────────── + return ( +
+ {/* Preloader global – visível até o primeiro carregamento de chats completar */} + + {!pageReady && ( + +
+ + + + + + + + + + + + + + Carregando conversas… +
+
+ )} +
+ +
+ + + {activeTab === 'chats' && ( +
+ {/* Header da sidebar */} +
+
+ +
+
setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1"> +
+ {activeInstance?.instance + ? { (e.currentTarget as HTMLImageElement).style.display = 'none' }} + /> + : } +
+ {activeInstance?.nome || 'Instância'} + +
+ + {isInstanceDropdownOpen && ( + + {instances.map(i => ( + + ))} + + )} + +
+
{/* end left flex */} +
+
+ + + {isOptionsMenuOpen && ( + + + + )} + +
+
+
+ + {/* Busca */} +
+
+ + setSearchTerm(e.target.value)} /> +
+ +
+ + {/* Lista de chats */} + +
+ )} + + {/* Área principal */} +
+ {/* Overlay branqueador sobre o padrão */} +
+ {activeTab === 'settings' ? ( + + ) : selectedChat ? ( + <> +
+ { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }} + onBack={() => handleSelectChat(null)} + onSyncAvatar={handleSyncAvatar} + isChatMenuOpen={isChatMenuOpen} + isSyncingAvatar={isSyncingAvatar} + isProtocolOpen={isProtocolOpen} + hasActiveProtocol={protocolPanel.hasActive} + onSearchClick={() => {}} + onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)} + onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }} + /> + {}} + onDelete={() => {}} + onEdit={() => {}} + quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']} + getGroupSenderLabel={(msg: any) => { + if (msg.direction === 'out') return 'Você' + if (msg.participant) return msg.participant + if (msg.senderJid) { + if (msg.senderJid.endsWith('@lid')) return msg.participant || '' + const phone = msg.senderJid.split('@')[0].split(':')[0] + return formatPhone(phone, msg.senderJid) + } + return '' + }} + /> + setReplyingTo(null)} + replyingTo={replyingTo} + selectedChat={selectedChat} + isMobile={isMobile} + onSendMedia={handleSendMedia} + onSendAudio={handleSendAudio} + instanceId={activeInstance?.instance ?? null} + mentions={mentions} + onMentionsChange={setMentions} + /> +
+ setIsProfileOpen(false)} + /> + setIsProtocolOpen(false)} + {...protocolPanel} + /> + + ) : ( +
+ +

NewWhats

+

Selecione uma conversa para começar.

+
+ )} +
+
+ + setIsNewConvoModalOpen(false)} + activeInstance={mappedActiveInstance as any} + connectedInstances={connectedInstances as any} + sendMessage={async (instanceId: string, phone: string, text: string) => { + try { + // ext: inicia nova conversa por telefone (resolve/cria o chat). + const res = await nw.post('/conversations', { sessionId: instanceId, phone, text }) + return { ok: true, messageId: res?.messageId } + } catch { + return { ok: false } + } + }} + /> +
+ ) +} diff --git a/frontend/views/newwhats/SecretariaView.tsx b/frontend/views/newwhats/SecretariaView.tsx new file mode 100644 index 0000000..36842d9 --- /dev/null +++ b/frontend/views/newwhats/SecretariaView.tsx @@ -0,0 +1,1741 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { format, parseISO } from 'date-fns' +import { ptBR } from 'date-fns/locale' +import { + MessageSquare, Brain, CalendarDays, Bot, Plus, Trash2, Pencil, + ChevronDown, Check, Send, X, Loader2, ToggleLeft, ToggleRight, + Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle, + CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu, + Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope, + Maximize2, Save, HelpCircle, ArrowLeft, +} from 'lucide-react' +import { useSecretariaStore } from './store/secretariaStore' +import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi' + +// ─── Thin Nav ───────────────────────────────────────────────────────────────── + +type NavTab = 'conversations' | 'brain' | 'calendar' | 'agents' | 'numbers' + +const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [ + { id: 'conversations', icon: MessageSquare, label: 'Conversas' }, + { id: 'brain', icon: Brain, label: 'Cérebro' }, + { id: 'calendar', icon: CalendarDays, label: 'Agenda' }, + { id: 'agents', icon: Bot, label: 'Agentes' }, + { id: 'numbers', icon: Phone, label: 'Números' }, +] + +function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void }) { + return ( +
+ {onBack && ( + + )} + {NAV_ITEMS.map(({ id, icon: Icon, label }) => ( + + ))} + +
+ + {/* Ajuda — modelos de como usar e configurar a secretária */} + +
+ ) +} + +// ─── Agent Dropdown ─────────────────────────────────────────────────────────── + +function AgentDropdown({ + agents, selected, onChange, +}: { agents: SecAgent[]; selected: SecAgent | null; onChange: (a: SecAgent) => void }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } + document.addEventListener('mousedown', h) + return () => document.removeEventListener('mousedown', h) + }, []) + + return ( +
+ + + + {open && ( + + {agents.map((a) => ( + + ))} + + )} + +
+ ) +} + +// ─── Conversation List ──────────────────────────────────────────────────────── + +const STATUS_META = { + active: { label: 'Ativa', color: 'text-green-400', dot: 'bg-green-400' }, + closed: { label: 'Encerrada', color: 'text-slate-500', dot: 'bg-slate-600' }, + escalated: { label: 'Escalada', color: 'text-yellow-400', dot: 'bg-yellow-400' }, +} + +function ConversationList({ + conversations, selected, onSelect, onCreate, onDelete, loadingConversations, selectedAgent, +}: { + conversations: SecConversation[] + selected: SecConversation | null + onSelect: (c: SecConversation) => void + onCreate: () => void + onDelete: (id: string) => void + loadingConversations: boolean + selectedAgent: SecAgent | null +}) { + return ( +
+
+ + Sessões de Teste + + +
+ + {loadingConversations ? ( +
+ +
+ ) : conversations.length === 0 ? ( +

+ {selectedAgent ? 'Nenhuma sessão. Clique em + para iniciar.' : 'Selecione um agente.'} +

+ ) : ( +
+ {conversations.map((c) => { + const m = STATUS_META[c.status] + const isActive = c.id === selected?.id + return ( +
onSelect(c)} + > +
+ +
+
+

+ {c.contact_name} +

+
+ + {m.label} + + {format(parseISO(c.updated_at), "dd/MM HH:mm", { locale: ptBR })} + +
+
+ +
+ ) + })} +
+ )} +
+ ) +} + +// ─── Modelos por provider ───────────────────────────────────────────────────── + +const MODELS_BY_PROVIDER: Record = { + openai: [ + { label: 'GPT-4o', value: 'gpt-4o' }, + { label: 'GPT-4o Mini', value: 'gpt-4o-mini' }, + { label: 'GPT-3.5 Turbo', value: 'gpt-3.5-turbo' }, + ], + anthropic: [ + { label: 'Claude Sonnet 4.5', value: 'claude-sonnet-4-5' }, + { label: 'Claude 3.5 Haiku', value: 'claude-3-5-haiku-20241022' }, + { label: 'Claude 3.5 Sonnet', value: 'claude-3-5-sonnet-20241022' }, + ], + gemini: [ + { label: 'Gemini 2.0 Flash', value: 'gemini-2.0-flash' }, + { label: 'Gemini 2.0 Flash Lite', value: 'gemini-2.0-flash-lite' }, + { label: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro-preview-03-25' }, + { label: 'Gemini 1.5 Pro', value: 'gemini-1.5-pro-latest' }, + ], + ollama: [ + { label: 'Llama 3.1', value: 'llama3.1' }, + { label: 'Mistral', value: 'mistral' }, + { label: 'Gemma 2', value: 'gemma2' }, + { label: 'Qwen 2.5', value: 'qwen2.5' }, + ], +} + +const ALL_MODELS = Object.values(MODELS_BY_PROVIDER).flat() + +function shortModelLabel(model: string | null): string { + if (!model) return '' + const found = ALL_MODELS.find(m => m.value === model) + if (found) return found.label.split(' ').slice(0, 3).join(' ') + // fallback: última parte do nome + return model.split('-').slice(-2).join('-') +} + +// ─── Brain Nodes Panel ──────────────────────────────────────────────────────── + +const NODE_META: Record = { + persona: { label: 'Persona', icon: Sparkles, color: 'text-purple-400', bg: 'bg-purple-500/10' }, + knowledge: { label: 'Conhecimento', icon: BookOpen, color: 'text-blue-400', bg: 'bg-blue-500/10' }, + rules: { label: 'Regras', icon: ShieldCheck, color: 'text-orange-400', bg: 'bg-orange-500/10' }, + calendar: { label: 'Agenda', icon: Calendar, color: 'text-green-400', bg: 'bg-green-500/10' }, + escalation: { label: 'Escalada', icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-500/10' }, +} + +function BrainPanel({ + nodes, loadingNodes, selectedAgent, + onAdd, onUpdate, onDelete, onOpen, openNodeId, +}: { + nodes: BrainNode[] + loadingNodes: boolean + selectedAgent: SecAgent | null + onAdd: (type: BrainNodeType) => void + onUpdate: (id: string, d: Partial) => void + onDelete: (id: string) => void + onOpen: (node: BrainNode) => void + openNodeId: string | null +}) { + const [editingId, setEditingId] = useState(null) + const [editContent, setEditContent] = useState('') + const [editTitle, setEditTitle] = useState('') + const [showAddMenu, setShowAddMenu] = useState(false) + const [modelPickerFor, setModelPickerFor] = useState(null) + + const startEdit = (node: BrainNode) => { + setEditingId(node.id) + setEditTitle(node.title) + setEditContent(node.content) + } + + const saveEdit = (node: BrainNode) => { + onUpdate(node.id, { title: editTitle, content: editContent }) + setEditingId(null) + } + + // Modelos disponíveis baseados no provider do agente selecionado + const providerModels = selectedAgent + ? (MODELS_BY_PROVIDER[selectedAgent.provider] ?? ALL_MODELS) + : ALL_MODELS + + return ( +
+
+ + Nós do Cérebro + +
+ + + {showAddMenu && ( + + {(Object.keys(NODE_META) as BrainNodeType[]).map((type) => { + const m = NODE_META[type] + const Icon = m.icon + return ( + + ) + })} + + )} + +
+
+ + {loadingNodes ? ( +
+ +
+ ) : nodes.length === 0 ? ( +

+ {selectedAgent ? 'Nenhum nó. Clique em + para adicionar.' : 'Selecione um agente.'} +

+ ) : ( +
+ {nodes.map((node) => { + const m = NODE_META[node.type as BrainNodeType] ?? NODE_META.knowledge + const Icon = m.icon + const isEditing = editingId === node.id + const isPickingModel = modelPickerFor === node.id + const hasModel = !!node.node_model + const isOpen = openNodeId === node.id + + return ( +
+ {/* Node Header */} +
+
+ +
+
+ {isEditing ? ( + setEditTitle(e.target.value)} + className="w-full text-xs font-bold bg-transparent text-white border-b border-white/20 focus:outline-none" + /> + ) : ( +

{node.title}

+ )} +

{m.label}

+
+
+ {/* Abrir no editor principal */} + + {/* Toggle active */} + + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + {/* Model picker row — apenas para nó persona (sobrepõe o modelo do agente) */} + {node.type === 'persona' && ( +
+ + + + {isPickingModel && ( + + {/* Opção: usar padrão do agente */} + +
+ {providerModels.map((mdl) => ( + + ))} + + )} + +
+ )} + + {/* Node Content */} + {isEditing ? ( +