Compare commits

...

28 Commits

Author SHA1 Message Date
Cauê Faleiros
509ed4a0d9 update docker compose again uhuu
All checks were successful
Build and Deploy / build-and-push (push) Successful in 57s
2026-03-25 16:43:07 -03:00
07cb43d0e3 revert b8541c0d24
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
revert update docker compose again
2026-03-25 19:42:03 +00:00
Cauê Faleiros
b8541c0d24 update docker compose again
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:39:21 -03:00
Cauê Faleiros
062864364a update docker compose
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
2026-03-25 16:38:23 -03:00
Cauê Faleiros
d148984028 chore: remove temporary postgres testing service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s
- The portainer webhook testing service fulfilled its purpose and is no longer needed.
2026-03-25 15:03:39 -03:00
Cauê Faleiros
fa683ab28c chore: add dummy postgres service for portainer debugging
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Added a simple postgres service to test if Portainer is successfully pulling and applying stack updates from the repository.
2026-03-25 14:48:24 -03:00
Cauê Faleiros
83e6da2d56 fix: add missing swarm deploy block to mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m9s
- Docker Swarm (Portainer) ignores 'restart: always' and often requires a 'deploy' block with replica counts to properly schedule a new service.

- Replaced standard docker-compose restart policy with a Swarm-compliant deploy block.
2026-03-25 14:43:03 -03:00
Cauê Faleiros
4dbd7c62cd fix: replace deprecated mysql backup image with modern cron backup container
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m5s
- The 'databack/mysql-backup' image crashes immediately when used in a pure cron context without legacy arguments and is incompatible with MySQL 8 caching_sha2_password.

- Switched to the actively maintained 'fradelg/mysql-cron-backup' image.

- Re-mapped environment variables to match the new image expectations (MYSQL_HOST, CRON_TIME, MAX_BACKUPS).

- Updated volume mapping destination to '/backup' as expected by the new image.
2026-03-25 14:34:36 -03:00
Cauê Faleiros
b5c8e97701 fix: revert backup volume path to exact specification
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
- Updated the host bind mount path to exactly '/opt/backups_db' as explicitly required by the server configuration.
2026-03-25 14:20:05 -03:00
Cauê Faleiros
f65ff97434 fix: change mysql backup volume path to prevent permission denied errors
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m7s
- Updated the host bind mount from the restricted '/root' directory to '/opt/fasto_backups' to ensure the Docker daemon has the necessary permissions to read and write database dumps.
2026-03-25 14:18:16 -03:00
Cauê Faleiros
958a2cdbd9 chore: force recreate database init sql config in docker swarm
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Appended a version suffix to the `init_sql` config name to bypass Docker Swarm's immutable config cache and force the cluster to pick up the latest database schema changes on deployment.
2026-03-25 13:55:31 -03:00
Cauê Faleiros
eb483f903b chore: remove swarm deployment constraint from mysql backup service
All checks were successful
Build and Deploy / build-and-push (push) Successful in 58s
- Dropped the 'node.role == manager' deployment constraint to allow the backup container to be scheduled on any available node or to run smoothly in non-swarm Docker Compose environments.
2026-03-25 13:23:34 -03:00
Cauê Faleiros
9ffcfcdcc8 chore: add automated database backup service and tighten backend security
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m56s
- Added `databack/mysql-backup` service to the production docker-compose Swarm stack, scheduling a daily 02:55 AM cron backup of the database with a 3-day local retention policy.

- Fixed a critical race condition in the backend JWT authentication middleware where an invalid token returning 401 could crash the response flow if the route executed before the defensive checks caught it.

- Added strict undefined defensive checks to the `getUserById` endpoint and RBAC middleware to gracefully reject requests that somehow bypass the token parser.

- Updated `GEMINI.md` technical documentation to fully match the real codebase logic.

- Fixed UX rule to prevent `manager` role from seeing Funnels or Origins tabs in the sidebar.

- Blocked `agent` role from modifying their own 'fullName' string in the Profile UI.
2026-03-25 12:40:53 -03:00
Cauê Faleiros
3663d03cb9 refactor(rbac): complete the removal of 'owner' role from backend routes and logic
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m48s
- Cleaned up the requireRole middleware across all Funnel and Origin API routes to strictly allow only 'admin' and 'super_admin' to perform structural changes.

- Updated the tenant creation script to assign the 'admin' role to new signups instead of 'owner'.
2026-03-23 15:40:36 -03:00
Cauê Faleiros
2317c46ac9 fix: change expired JWT response code to 401 to properly trigger frontend interceptor
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m42s
- The backend was returning 403 Forbidden when a token expired, causing the frontend apiFetch interceptor (which listens for 401) to ignore it and crash the session.
2026-03-19 16:31:20 -03:00
Cauê Faleiros
4489f0a74d refactor: completely remove 'owner' role from RBAC system
- The platform now strictly uses 'super_admin', 'admin', 'manager', and 'agent' to simplify permissions and match business requirements.
2026-03-19 15:33:16 -03:00
Cauê Faleiros
327ad064a4 feat: implement secure 2-token authentication with rolling sessions
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m43s
- Refactored POST /auth/login to issue a 15-minute Access Token and a 30-day Refresh Token.

- Added POST /auth/refresh endpoint to automatically issue new Access Tokens and extend the Refresh Token's lifespan by 30 days upon use (Sliding Expiration).

- Built an HTTP interceptor wrapper (apiFetch) in dataService.ts that automatically catches 401 Unauthorized errors, calls the refresh endpoint, updates localStorage, and silently retries the original request without logging the user out.
2026-03-19 14:45:53 -03:00
Cauê Faleiros
8f7e5ee487 feat: synchronize dashboard origins with management page and add integration endpoints
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
- Updated Dashboard origin chart to strictly reflect only configured origins, grouping unmapped data into an 'Outros' category.

- Added GET /api/integration/funnels and GET /api/integration/origins endpoints to allow external AIs to dynamically map stages and lead sources.
2026-03-18 16:43:42 -03:00
Cauê Faleiros
f11db95a2f fix: allow origin colors to be edited and display correctly in dashboard
- Fixed database initialization where default origins were seeded without color_classes.

- Added a visual color picker to the Origens admin page to allow users to assign colors to origin tags.

- Updated Dashboard Pie Chart to read the color classes correctly and display them.
2026-03-18 13:43:43 -03:00
Cauê Faleiros
1d3315a1d0 feat: implement relational lead origins with team assignments
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m51s
- Dropped simple origins table in favor of origin_groups and origin_items to match the Funnels architecture.

- Added origin_group_id to teams table to assign specific origins to specific teams.

- Updated /admin/origins page to support creating origin groups, adding origin items to them, and assigning teams to groups.

- Updated Dashboard and UserDetail pages to dynamically load the exact origin items belonging to the active team/user.
2026-03-18 11:18:30 -03:00
Cauê Faleiros
64c4ca8fb5 style: add 2-digit year to calendar date display
All checks were successful
Build and Deploy / build-and-push (push) Successful in 53s
- Updated the DateRangePicker component to display the date in DD/MM/YY format (e.g. 18/03/26) instead of just DD/MM for better clarity.
2026-03-18 09:39:55 -03:00
Cauê Faleiros
47799990e3 fix: auto-redirect super admins to the super admin panel from root
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m45s
- Updated AuthGuard to intercept navigation to the standard dashboard ('/') for users with the 'super_admin' role and automatically redirect them to '/super-admin'.
2026-03-18 09:32:21 -03:00
Cauê Faleiros
22a1228a60 fix: resolve login persistence bug and aggressive logout on network blips
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m58s
- Updated Login.tsx to automatically redirect users to the dashboard if a valid token is already present in localStorage.

- Refactored getUserById to properly throw server/network errors instead of silently returning undefined.

- Updated AuthGuard in App.tsx to gracefully handle network errors without destroying the user's valid localStorage tokens.
2026-03-17 15:40:13 -03:00
Cauê Faleiros
f884f6dc3c fix: resolve date range picker timezone offset bug
All checks were successful
Build and Deploy / build-and-push (push) Successful in 56s
- Fixed a bug where selecting a date in the native date picker resulted in the previous day being selected due to the browser converting the 'YYYY-MM-DD' string to UTC midnight and then shifting it back to local time (e.g. UTC-3 in Brazil).

- Explicitly parsed the date string and constructed the Date object using local time coordinates to ensure visual and data consistency.
2026-03-17 14:12:20 -03:00
Cauê Faleiros
a6686c6f7c style: force brazilian locale formatting on all date pickers
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m4s
- Updated the DateRangePicker component to visually display dates in DD/MM/YYYY format using a focus/blur technique, overriding the browser's default OS language formatting.
2026-03-17 13:40:29 -03:00
Cauê Faleiros
96cfb3d125 refactor: remove mock data and finalize n8n data schema
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m50s
- Removed all hardcoded MOCK_ATTENDANCES, USERS, and TENANTS generators from constants.ts since the system is now production-ready.

- Renamed 'summary' to 'title' in the database and across all frontend components for clarity.

- Added 'full_summary' to the attendances schema to explicitly store the large, detailed AI analysis texts from n8n.

- Updated the 'Resumo da Interação' UI to render the 'full_summary' without adding any artificial filler text.

- Localized all dates and times across the dashboard to Brazilian formatting (pt-BR).
2026-03-17 12:45:15 -03:00
Cauê Faleiros
baa1bd66f6 fix: resolve sidebar active state bug and update navigation labels
- Added 'end' prop to NavLink in SidebarItem to ensure exact route matching.

- Renamed 'Integrações (API)' to 'Integrações' for a cleaner UI.
2026-03-16 14:49:37 -03:00
Cauê Faleiros
fbf3edb7a1 feat: migrate api key management to dedicated super admin page
- Extracted API Key generation and management from UserProfile to a new /super-admin/api-keys route.

- Added cross-tenant selection in the new ApiKeys page so Super Admins can manage integrations for any organization.
2026-03-16 14:44:16 -03:00
16 changed files with 1541 additions and 530 deletions

177
App.tsx
View File

@@ -1,32 +1,48 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import {
import { Layout } from './components/Layout'; HashRouter as Router,
import { Dashboard } from './pages/Dashboard'; Routes,
import { UserDetail } from './pages/UserDetail'; Route,
import { AttendanceDetail } from './pages/AttendanceDetail'; Navigate,
import { SuperAdmin } from './pages/SuperAdmin'; useLocation,
import { TeamManagement } from './pages/TeamManagement'; } from "react-router-dom";
import { Teams } from './pages/Teams'; import { Layout } from "./components/Layout";
import { Funnels } from './pages/Funnels'; import { Dashboard } from "./pages/Dashboard";
import { Login } from './pages/Login'; import { UserDetail } from "./pages/UserDetail";
import { ForgotPassword } from './pages/ForgotPassword'; import { AttendanceDetail } from "./pages/AttendanceDetail";
import { ResetPassword } from './pages/ResetPassword'; import { SuperAdmin } from "./pages/SuperAdmin";
import { SetupAccount } from './pages/SetupAccount'; import { ApiKeys } from "./pages/ApiKeys";
import { UserProfile } from './pages/UserProfile'; import { TeamManagement } from "./pages/TeamManagement";
import { getUserById, logout } from './services/dataService'; import { Teams } from "./pages/Teams";
import { User } from './types'; import { Funnels } from "./pages/Funnels";
import { Origins } from "./pages/Origins";
import { Login } from "./pages/Login";
import { ForgotPassword } from "./pages/ForgotPassword";
import { ResetPassword } from "./pages/ResetPassword";
import { SetupAccount } from "./pages/SetupAccount";
import { UserProfile } from "./pages/UserProfile";
import { getUserById, logout } from "./services/dataService";
import { User } from "./types";
const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({ children, roles }) => { const AuthGuard: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
children,
roles,
}) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
const storedUserId = localStorage.getItem('ctms_user_id'); const storedUserId = localStorage.getItem("ctms_user_id");
const storedToken = localStorage.getItem('ctms_token'); const storedToken = localStorage.getItem("ctms_token");
if (!storedUserId || !storedToken || storedToken === 'undefined' || storedToken === 'null') { if (
!storedUserId ||
!storedToken ||
storedToken === "undefined" ||
storedToken === "null"
) {
if (storedToken) logout(); // Limpar se for "undefined" string if (storedToken) logout(); // Limpar se for "undefined" string
setLoading(false); setLoading(false);
return; return;
@@ -34,15 +50,27 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
try { try {
const fetchedUser = await getUserById(storedUserId); const fetchedUser = await getUserById(storedUserId);
if (fetchedUser && fetchedUser.status === 'active') { if (fetchedUser) {
setUser(fetchedUser); if (fetchedUser.status === "active") {
setUser(fetchedUser);
} else {
// User explicitly marked inactive or deleted
logout();
setUser(null);
}
} else { } else {
// If fetchedUser is undefined but didn't throw, it usually means a 401/403/404 (invalid token or user missing).
// However, to be safe against random failures, we should only clear if we are sure it's invalid.
// For now, if the token is completely rejected, we log out.
logout(); logout();
setUser(null); setUser(null);
} }
} catch (err) { } catch (err) {
console.error("Auth check failed", err); console.error("Auth check failed (network/server error):", err);
logout(); // DO NOT logout() here. If the server is offline or restarting,
// we shouldn't wipe the user's local storage tokens.
// We just leave the user as null, which will redirect them to login,
// but their tokens remain so they can auto-login when the server is back.
setUser(null); setUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -52,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
}, [location.pathname]); }, [location.pathname]);
if (loading) { if (loading) {
return <div className="flex h-screen items-center justify-center bg-slate-50 text-slate-400">Carregando...</div>; return (
<div className="flex h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950 text-zinc-400">
Carregando...
</div>
);
} }
if (!user) { if (!user) {
@@ -63,6 +95,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
// Auto-redirect Super Admins away from the standard dashboard to their specific panel
if (location.pathname === "/" && user.role === "super_admin") {
return <Navigate to="/super-admin" replace />;
}
return <Layout>{children}</Layout>; return <Layout>{children}</Layout>;
}; };
@@ -74,14 +111,86 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} /> <Route path="/setup-account" element={<SetupAccount />} />
<Route path="/" element={<AuthGuard><Dashboard /></AuthGuard>} /> <Route
<Route path="/admin/users" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><TeamManagement /></AuthGuard>} /> path="/"
<Route path="/admin/teams" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Teams /></AuthGuard>} /> element={
<Route path="/admin/funnels" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Funnels /></AuthGuard>} /> <AuthGuard>
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} /> <Dashboard />
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} /> </AuthGuard>
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></AuthGuard>} /> }
<Route path="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} /> />
<Route
path="/admin/users"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<TeamManagement />
</AuthGuard>
}
/>
<Route
path="/admin/teams"
element={
<AuthGuard roles={["super_admin", "admin", "manager"]}>
<Teams />
</AuthGuard>
}
/>
<Route
path="/admin/funnels"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Funnels />
</AuthGuard>
}
/>
<Route
path="/admin/origins"
element={
<AuthGuard roles={["super_admin", "admin"]}>
<Origins />
</AuthGuard>
}
/>
<Route
path="/users/:id"
element={
<AuthGuard>
<UserDetail />
</AuthGuard>
}
/>
<Route
path="/attendances/:id"
element={
<AuthGuard>
<AttendanceDetail />
</AuthGuard>
}
/>
<Route
path="/super-admin"
element={
<AuthGuard roles={["super_admin"]}>
<SuperAdmin />
</AuthGuard>
}
/>
<Route
path="/super-admin/api-keys"
element={
<AuthGuard roles={["super_admin"]}>
<ApiKeys />
</AuthGuard>
}
/>
<Route
path="/profile"
element={
<AuthGuard>
<UserProfile />
</AuthGuard>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Router> </Router>

View File

@@ -7,38 +7,44 @@ Fasto is a commercial team management system built with React (Vite) on the fron
We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**: We have transitioned from a mock-based prototype to a **secure, multi-tenant production architecture**:
- **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another. - **Multi-Tenancy & Data Isolation:** All backend routes (Users, Teams, Attendances) now strictly enforce `tenant_id` checks. It is technically impossible for one organization to query data from another.
- **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords, while strictly maintaining state isolation through forced React reloads and locking mechanisms. - **Advanced 2-Token Authentication (Rolling Sessions):**
- **Dynamic Funnel Customization:** - Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Funnel stages are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnels via the `funnels` and `funnel_stages` tables. - Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- Managers can assign specific Teams to specific Funnels. - Built an Axios-like `apiFetch` interceptor on the frontend that automatically catches 401 Unauthorized errors, fetches a new Access Token in the background, extends the Refresh Token by another 30 days (Sliding Expiration), and retries the original request without logging the user out.
- The Dashboard, User Detail, and Attendance Detail pages now dynamically map and color-code stages based on the active team's assigned funnel. - Full remote revocation capability (Logout drops the token from the DB immediately).
- **Real-Time Notification System:** - **God Mode (Tenant Impersonation):** Super Admins can securely impersonate Tenant Admins via a specialized, temporary JWT (`/api/impersonate/:tenantId`). This allows seamless cross-domain support without storing passwords.
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback. - **Role-Based Access Control (RBAC) Simplification:**
- Automated Triggers: Super Admins are notified of new organizations; Tenant Admins/Managers are notified of new user setups; Users are notified of team assignment changes. - Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Role-Based Access Control (RBAC):** - **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant). - **Admin:** Full control over members, teams, funnels, and origins within their specific organization.
- **Admin/Manager:** Full control over members and teams within their specific organization. - **Manager:** Mid-level control. Can edit basic info of users in their specific team, but cannot change user roles or re-assign users to different teams (only Admins can).
- **Agent:** Restricted access. Can only view their own performance metrics and historical attendances. - **Agent:** Restricted access. Can only view their own performance metrics and historical attendances.
- **Dynamic Funnel & Origin Managers:**
- Funnel stages and Lead Origins are no longer hardcoded ENUMs. Each tenant can create multiple dynamic funnel/origin groups via relational tables (`funnels`, `funnel_stages`, `origin_groups`, `origin_items`).
- Admins can customize the exact Tailwind color class (e.g., "bg-green-100") for each stage and origin via visual UI pickers.
- Admins assign specific Teams to specific Funnels/Origin Groups.
- The Dashboard pie charts and data tables strictly filter and color-code data based on the active team's configuration. Deleted data falls back to an "Outros" category to prevent chart breakage.
- **n8n / External API Webhooks (Completed):**
- Super Admins can generate persistent `api_keys` for specific tenants.
- `GET /api/integration/users`, `/funnels`, and `/origins` allow the n8n AI to dynamically map the tenant's actual agents and workflow stages before processing a chat.
- `POST /api/integration/attendances` accepts the AI's final JSON payload (including the `full_summary` text) and injects it directly into the dashboard.
- **Real-Time Notification System:**
- Built a persistent notification tray (`/api/notifications`) with real-time polling (10s intervals) and a hidden HTML5 `<audio>` player for cross-browser sound playback (custom `.mp3` loaded via Vite).
- Automated Triggers: Super Admins are notified of new organizations; Admins/Super Admins are notified of new user setups; Agents are notified of team assignment changes; Managers get "Venda Fechada" alerts when n8n posts a converted lead.
- **Enhanced UI/UX:** - **Enhanced UI/UX:**
- Premium "Onyx & Gold" True Black dark mode. - Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`). - Fully collapsible interactive sidebar with memory (`localStorage`).
- Action buttons across all data tables are permanently visible for faster discoverability (removed hover requirements). - All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
- Loading states embedded directly into action buttons to prevent double-submissions.
- **Secure File Uploads:** Profile avatars use `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation.
## 📌 Roadmap / To-Do ## 📌 Roadmap / To-Do
- [ ] **n8n / External API Integration (Priority 1):** - [ ] **Advanced AI Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance payload from n8n receives a critically low quality score (`score < 50`), or breaches a specific Response Time SLA (e.g., `first_response_time_min > 60`).
- Create an `api_keys` table to allow generating persistent, secure API keys for each Tenant.
- Build `GET /api/integration/users` so n8n can map its chat agents to Fasto's internal `user_id`s.
- Build `POST /api/integration/attendances` to allow n8n to programmatically create new attendances linked to specific tenants and users.
- [ ] **Sales & Quality Notification Triggers:** Implement backend logic to automatically notify Managers when an attendance is marked as "Won" (Ganhos), receives a critically low quality score, or breaches a specific Response Time SLA.
- [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel. - [ ] **Data Export/Reporting:** Allow Admins to export attendance and KPI data to CSV/Excel.
- [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically. - [ ] **Billing/Subscription Management:** Integrate a payment gateway (e.g., Stripe/Asaas) to manage tenant trial periods and active statuses dynamically.
## 🛠 Architecture ## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React. - **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer. - **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
- **Database**: MySQL 8.0 (Schema: `fasto_db`). - **Database**: MySQL 8.0 (Schema: `fasto_db` or `agenciac_comia` depending on `.env`).
- **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook. - **Deployment**: Docker Compose for local development; Gitea Actions for CI/CD pushing to a Gitea Registry and deploying via Portainer webhook.
## 📋 Prerequisites ## 📋 Prerequisites
@@ -55,7 +61,7 @@ cp .env.example .env
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes. *Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
### 2. Database ### 2. Database
The project expects a MySQL database. The `docker-compose.local.yml` initializes it. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `notifications`, `funnels`, `funnel_stages`, and modifying `attendances`). The project expects a MySQL database. The Node.js backend automatically runs non-destructive schema migrations on startup (adding tables like `refresh_tokens`, `api_keys`, `origin_groups`, etc.).
### 3. Running Locally (Docker Compose) ### 3. Running Locally (Docker Compose)
To start the application and database locally: To start the application and database locally:

View File

@@ -136,14 +136,17 @@ const authenticateToken = async (req, res, next) => {
if (!token) return res.status(401).json({ error: 'Token não fornecido.' }); if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
jwt.verify(token, JWT_SECRET, (err, user) => { try {
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' }); const user = jwt.verify(token, JWT_SECRET);
req.user = user; req.user = user;
next(); next();
}); } catch (err) {
return res.status(401).json({ error: 'Token inválido ou expirado.' });
}
}; };
const requireRole = (roles) => (req, res, next) => { const requireRole = (roles) => (req, res, next) => {
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' }); if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
next(); next();
}; };
@@ -203,11 +206,10 @@ apiRouter.post('/auth/verify', async (req, res) => {
const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`; const tenantId = `tenant_${crypto.randomUUID().split('-')[0]}`;
const userId = `u_${crypto.randomUUID().split('-')[0]}`; const userId = `u_${crypto.randomUUID().split('-')[0]}`;
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)', await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]); [tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']); [userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.commit(); await connection.commit();
res.json({ message: 'Sucesso.' }); res.json({ message: 'Sucesso.' });
@@ -236,8 +238,78 @@ apiRouter.post('/auth/login', async (req, res) => {
const valid = await bcrypt.compare(password, user.password_hash); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' }); if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' }); // Generate Access Token (short-lived)
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } }); const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
// Generate Refresh Token (long-lived)
const refreshToken = crypto.randomBytes(40).toString('hex');
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
// Store Refresh Token in database (expires in 30 days)
await pool.query(
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[refreshId, user.id, refreshToken]
);
res.json({
token,
refreshToken,
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Refresh Token
apiRouter.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
try {
// Verifies if the token exists and hasn't expired
const [tokens] = await pool.query(
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
[refreshToken]
);
if (tokens.length === 0) {
// If invalid, optionally delete the bad token if it's just expired
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
}
const user = tokens[0];
if (user.status !== 'active') {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
return res.status(403).json({ error: 'Sua conta está inativa.' });
}
// Sliding Expiration: Extend the refresh token's life by another 30 days
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
// Issue a new short-lived access token
const newAccessToken = jwt.sign(
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token: newAccessToken });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Logout (Revoke Refresh Token)
apiRouter.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
if (refreshToken) {
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
}
res.json({ message: 'Logout bem-sucedido.' });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
@@ -388,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]); const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (!req.user) return res.status(401).json({ error: 'Não autenticado' }); if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
@@ -471,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
const isSelf = req.user.id === req.params.id; const isSelf = req.user.id === req.params.id;
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role); const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
if (!isSelf && !isManagerOrAdmin) { if (!isSelf && !isManagerOrAdmin) {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
@@ -481,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
return res.status(403).json({ error: 'Acesso negado.' }); return res.status(403).json({ error: 'Acesso negado.' });
} }
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role; // Only Admins can change roles and teams. Managers can only edit basic info of their team members.
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id; const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status; const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
const finalEmail = email !== undefined ? email : existing[0].email; const finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true); const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
@@ -514,7 +588,7 @@ apiRouter.put('/users/:id', async (req, res) => {
} }
}); });
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -612,6 +686,134 @@ apiRouter.delete('/notifications/:id', async (req, res) => {
} }
}); });
// --- Origin Routes (Groups & Items) ---
apiRouter.get('/origins', async (req, res) => {
try {
const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
// Seed default origin group if none exists
if (groups.length === 0) {
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']);
const defaultOrigins = [
{ name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
{ name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
{ name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
{ name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
{ name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' }
];
for (const origin of defaultOrigins) {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, gid, origin.name, origin.color]
);
}
// Update all teams of this tenant to use this origin group if they have none
await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]);
groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' });
}
const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]);
const result = groups.map(g => ({
...g,
items: items.filter(i => i.origin_group_id === g.id),
teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id)
}));
res.json(result);
} catch (error) {
console.error("GET /origins error:", error);
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try {
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]);
res.status(201).json({ id: gid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body;
try {
if (name) {
await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]);
}
if (teamIds && Array.isArray(teamIds)) {
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
if (teamIds.length > 0) {
await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
}
}
res.json({ message: 'Origin group updated.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]);
res.json({ message: 'Origin group deleted.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body;
try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
);
res.status(201).json({ id: oid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class } = req.body;
try {
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' });
await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]);
res.json({ message: 'Origin item updated.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
res.json({ message: 'Origin item deleted.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- Funnel Routes --- // --- Funnel Routes ---
apiRouter.get('/funnels', async (req, res) => { apiRouter.get('/funnels', async (req, res) => {
try { try {
@@ -664,7 +866,7 @@ apiRouter.get('/funnels', async (req, res) => {
} }
}); });
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -676,7 +878,7 @@ apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admi
} }
}); });
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body; const { name, teamIds } = req.body;
try { try {
if (name) { if (name) {
@@ -694,7 +896,7 @@ apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_a
} }
}); });
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]); await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
@@ -705,7 +907,7 @@ apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'supe
} }
}); });
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const sid = `stage_${crypto.randomUUID().split('-')[0]}`; const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
@@ -719,7 +921,7 @@ apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager',
} }
}); });
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body; const { name, color_class, order_index } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
@@ -735,7 +937,7 @@ apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 's
} }
}); });
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
res.json({ message: 'Stage deleted.' }); res.json({ message: 'Stage deleted.' });
@@ -760,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
membersQ += ' AND tenant_id = ?'; membersQ += ' AND tenant_id = ?';
membersParams.push(req.user.tenant_id); membersParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -778,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
teamsQ += ' AND tenant_id = ?'; teamsQ += ' AND tenant_id = ?';
teamsParams.push(req.user.tenant_id); teamsParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -796,12 +998,12 @@ apiRouter.get('/search', async (req, res) => {
} }
// 4. Search Attendances // 4. Search Attendances
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?'; let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?';
const attendancesParams = [queryStr]; const attendancesParams = [queryStr];
if (req.user.role === 'super_admin') { if (req.user.role === 'super_admin') {
// No extra filters // No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') { } else if (req.user.role === 'admin') {
attendancesQ += ' AND a.tenant_id = ?'; attendancesQ += ' AND a.tenant_id = ?';
attendancesParams.push(req.user.tenant_id); attendancesParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') { } else if (req.user.role === 'manager') {
@@ -892,7 +1094,7 @@ apiRouter.get('/attendances/:id', async (req, res) => {
}); });
// --- API Key Management Routes --- // --- API Key Management Routes ---
apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const { tenantId } = req.query; const { tenantId } = req.query;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
@@ -905,7 +1107,7 @@ apiRouter.get('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async
} }
}); });
apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, tenantId } = req.body; const { name, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -925,7 +1127,7 @@ apiRouter.post('/api-keys', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' }); if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
@@ -952,6 +1154,48 @@ apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) =>
} }
}); });
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
try {
const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
if (groups.length === 0) return res.json([]);
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
const result = groups.map(g => ({
group_name: g.name,
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
}));
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.get('/integration/funnels', requireRole(['admin']), async (req, res) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
try {
const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]);
if (funnels.length === 0) return res.json([]);
const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]);
const result = funnels.map(f => ({
funnel_name: f.name,
stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name),
assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name }))
}));
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => { apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' }); if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
@@ -959,7 +1203,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
user_id, user_id,
origin, origin,
funnel_stage, funnel_stage,
summary, title,
full_summary,
score, score,
first_response_time_min, first_response_time_min,
handling_time_min, handling_time_min,
@@ -970,8 +1215,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
improvement_points improvement_points
} = req.body; } = req.body;
if (!user_id || !origin || !funnel_stage || !summary) { if (!user_id || !origin || !funnel_stage || !title) {
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, summary' }); return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
} }
try { try {
@@ -982,16 +1227,17 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
const attId = `att_${crypto.randomUUID().split('-')[0]}`; const attId = `att_${crypto.randomUUID().split('-')[0]}`;
await pool.query( await pool.query(
`INSERT INTO attendances ( `INSERT INTO attendances (
id, tenant_id, user_id, summary, score, id, tenant_id, user_id, title, full_summary, score,
first_response_time_min, handling_time_min, first_response_time_min, handling_time_min,
funnel_stage, origin, product_requested, product_sold, funnel_stage, origin, product_requested, product_sold,
converted, attention_points, improvement_points converted, attention_points, improvement_points
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
attId, attId,
req.user.tenant_id, req.user.tenant_id,
user_id, user_id,
summary, title,
full_summary || null,
score || 0, score || 0,
first_response_time_min || 0, first_response_time_min || 0,
handling_time_min || 0, handling_time_min || 0,
@@ -1070,7 +1316,7 @@ apiRouter.get('/teams', async (req, res) => {
} }
}); });
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.post('/teams', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description, tenantId } = req.body; const { name, description, tenantId } = req.body;
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id; const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try { try {
@@ -1086,7 +1332,7 @@ apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (
} }
}); });
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.put('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, description } = req.body; const { name, description } = req.body;
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
@@ -1106,7 +1352,7 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
} }
}); });
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => { apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try { try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
@@ -1286,9 +1532,9 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message); if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
} }
// Update origin enum // Update origin to VARCHAR for custom origins
try { try {
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL"); await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");
} catch (err) { } catch (err) {
console.log('Schema update note (origin):', err.message); console.log('Schema update note (origin):', err.message);
} }
@@ -1300,6 +1546,66 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
console.log('Schema update note (funnel_stage):', err.message); console.log('Schema update note (funnel_stage):', err.message);
} }
// Add full_summary column for detailed AI analysis
try {
await connection.query("ALTER TABLE attendances ADD COLUMN full_summary TEXT DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message);
}
// Create origin_groups table
await connection.query(`
CREATE TABLE IF NOT EXISTS origin_groups (
id varchar(36) NOT NULL,
tenant_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Create origin_items table
await connection.query(`
CREATE TABLE IF NOT EXISTS origin_items (
id varchar(36) NOT NULL,
origin_group_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY origin_group_id (origin_group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Attempt to add color_class if table already existed without it
try {
await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message);
}
// Add origin_group_id to teams
try {
await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message);
}
// Rename summary to title
try {
await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title");
} catch (err) {
if (err.code !== 'ER_BAD_FIELD_ERROR' && err.code !== 'ER_DUP_FIELDNAME') {
// If RENAME COLUMN fails (older mysql), try CHANGE
try {
await connection.query("ALTER TABLE attendances CHANGE COLUMN summary title TEXT");
} catch (e) {
console.log('Schema update note (summary to title):', e.message);
}
}
}
// Create funnels table // Create funnels table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS funnels ( CREATE TABLE IF NOT EXISTS funnels (
@@ -1341,6 +1647,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`); `);
// Create refresh_tokens table for persistent sessions
await connection.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id varchar(36) NOT NULL,
user_id varchar(36) NOT NULL,
token varchar(255) NOT NULL,
expires_at timestamp NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Add funnel_id to teams // Add funnel_id to teams
try { try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL"); await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useRef } from 'react';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import { DateRange } from '../types'; import { DateRange } from '../types';
@@ -8,41 +8,95 @@ interface DateRangePickerProps {
} }
export const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => { export const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => {
const startRef = useRef<HTMLInputElement>(null);
const endRef = useRef<HTMLInputElement>(null);
const formatDateForInput = (date: Date) => { const formatDateForInput = (date: Date) => {
return date.toISOString().split('T')[0]; // Format to local YYYY-MM-DD to avoid timezone shifts
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatShortDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'America/Sao_Paulo' });
};
const parseLocalDate = (value: string) => {
// Split "YYYY-MM-DD" and create date in local timezone to avoid UTC midnight shift
if (!value) return null;
const [year, month, day] = value.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}; };
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = new Date(e.target.value); const newStart = parseLocalDate(e.target.value);
if (!isNaN(newStart.getTime())) { if (newStart && !isNaN(newStart.getTime())) {
onChange({ ...dateRange, start: newStart }); onChange({ ...dateRange, start: newStart });
} }
}; };
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = new Date(e.target.value); const newEnd = parseLocalDate(e.target.value);
if (!isNaN(newEnd.getTime())) { if (newEnd && !isNaN(newEnd.getTime())) {
// Set to end of day to ensure the query includes the whole day
newEnd.setHours(23, 59, 59, 999);
onChange({ ...dateRange, end: newEnd }); onChange({ ...dateRange, end: newEnd });
} }
}; };
const openPicker = (ref: React.RefObject<HTMLInputElement>) => {
if (ref.current) {
try {
if ('showPicker' in HTMLInputElement.prototype) {
ref.current.showPicker();
} else {
ref.current.focus();
}
} catch (e) {
ref.current.focus();
}
}
};
return ( return (
<div className="flex items-center gap-2 bg-white dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors"> <div className="flex items-center gap-2 bg-white dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg shadow-sm hover:border-zinc-300 dark:hover:border-dark-border transition-colors">
<Calendar size={16} className="text-zinc-500 dark:text-dark-muted shrink-0" /> <Calendar size={16} className="text-zinc-500 dark:text-dark-muted shrink-0" />
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
<input
type="date" {/* Start Date */}
value={formatDateForInput(dateRange.start)} <div
onChange={handleStartChange} className="relative cursor-pointer hover:text-brand-yellow transition-colors"
className="bg-transparent text-zinc-700 dark:text-zinc-200 font-medium outline-none cursor-pointer w-28 md:w-auto" onClick={() => openPicker(startRef)}
/> >
<span className="text-zinc-400 dark:text-dark-muted">até</span> {formatShortDate(dateRange.start)}
<input <input
type="date" ref={startRef}
value={formatDateForInput(dateRange.end)} type="date"
onChange={handleEndChange} value={formatDateForInput(dateRange.start)}
className="bg-transparent text-zinc-700 dark:text-zinc-200 font-medium outline-none cursor-pointer w-28 md:w-auto" onChange={handleStartChange}
/> className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
<span className="text-zinc-400 dark:text-dark-muted font-normal text-xs">até</span>
{/* End Date */}
<div
className="relative cursor-pointer hover:text-brand-yellow transition-colors"
onClick={() => openPicker(endRef)}
>
{formatShortDate(dateRange.end)}
<input
ref={endRef}
type="date"
value={formatDateForInput(dateRange.end)}
onChange={handleEndChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut, LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers, Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
ChevronLeft, ChevronRight ChevronLeft, ChevronRight, Key, Target
} from 'lucide-react'; } from 'lucide-react';
import { import {
getAttendances, getUsers, getUserById, logout, searchGlobal, getAttendances, getUsers, getUserById, logout, searchGlobal,
@@ -16,6 +16,7 @@ import notificationSound from '../src/assets/audio/notification.mp3';
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => ( const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
<NavLink <NavLink
to={to} to={to}
end
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${ `flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
isActive isActive
@@ -238,7 +239,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<> <>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} /> {currentUser.role !== 'manager' && (
<>
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
</>
)}
</> </>
)} )}
</> </>
@@ -249,14 +255,14 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<> <>
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<div className="pt-2 pb-2 px-4 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest whitespace-nowrap"> <div className="pt-2 pb-2 px-4 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest whitespace-nowrap">
Super Admin Super Admin
</div> </div>
)} )}
<SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={isSidebarCollapsed} /> <SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={isSidebarCollapsed} />
<SidebarItem to="/admin/users" icon={Users} label="Usuários Globais" collapsed={isSidebarCollapsed} /> <SidebarItem to="/admin/users" icon={Users} label="Usuários Globais" collapsed={isSidebarCollapsed} />
<SidebarItem to="/super-admin/api-keys" icon={Key} label="Integrações" collapsed={isSidebarCollapsed} />
</> </>
)} )} </nav>
</nav>
{/* User Profile Mini - Now Clickable to Profile */} {/* User Profile Mini - Now Clickable to Profile */}
<div className="p-3 border-t border-zinc-100 dark:border-dark-border space-y-3 shrink-0"> <div className="p-3 border-t border-zinc-100 dark:border-dark-border space-y-3 shrink-0">
@@ -442,10 +448,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
KPI KPI
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.summary}</div> <div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
<div className="text-[10px] text-zinc-500 dark:text-dark-muted flex justify-between mt-0.5"> <div className="text-[10px] text-zinc-500 dark:text-dark-muted flex justify-between mt-0.5">
<span className="font-medium">{a.user_name}</span> <span className="font-medium">{a.user_name}</span>
<span>{new Date(a.created_at).toLocaleDateString()}</span> <span>{new Date(a.created_at).toLocaleDateString('pt-BR')}</span>
</div> </div>
</div> </div>
</button> </button>
@@ -540,7 +546,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'} {n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
</span> </span>
<span className="text-[10px] text-zinc-400 dark:text-dark-muted"> <span className="text-[10px] text-zinc-400 dark:text-dark-muted">
{new Date(n.created_at).toLocaleDateString()} {new Date(n.created_at).toLocaleDateString('pt-BR')}
</span> </span>
</div> </div>
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div> <div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>

View File

@@ -1,164 +1,5 @@
import { Attendance, FunnelStage, Tenant, User } from './types'; import { FunnelStage } from './types';
export const TENANTS: Tenant[] = [
{
id: 'tenant_123',
name: 'Fasto Corp',
slug: 'fasto',
admin_email: 'admin@fasto.com',
status: 'active',
user_count: 12,
attendance_count: 1450,
created_at: '2023-01-15T10:00:00Z'
},
{
id: 'tenant_456',
name: 'Acme Inc',
slug: 'acme-inc',
admin_email: 'contact@acme.com',
status: 'trial',
user_count: 5,
attendance_count: 320,
created_at: '2023-06-20T14:30:00Z'
},
{
id: 'tenant_789',
name: 'Globex Utils',
slug: 'globex',
admin_email: 'sysadmin@globex.com',
status: 'inactive',
user_count: 2,
attendance_count: 45,
created_at: '2022-11-05T09:15:00Z'
},
{
id: 'tenant_101',
name: 'Soylent Green',
slug: 'soylent',
admin_email: 'admin@soylent.com',
status: 'active',
user_count: 25,
attendance_count: 5600,
created_at: '2023-02-10T11:20:00Z'
},
];
export const USERS: User[] = [
{
id: 'sa1',
tenant_id: 'system',
name: 'Super Administrator',
email: 'root@system.com',
role: 'super_admin',
team_id: '',
avatar_url: 'https://ui-avatars.com/api/?name=Super+Admin&background=0f172a&color=fff',
bio: 'Administrador do Sistema Global',
status: 'active'
},
{
id: 'u1',
tenant_id: 'tenant_123',
name: 'Lidya Chan',
email: 'lidya@fasto.com',
role: 'manager',
team_id: 'sales_1',
avatar_url: 'https://picsum.photos/id/1011/200/200',
bio: 'Gerente de Vendas com mais de 10 anos de experiência em SaaS. Apaixonada por construção de equipes e crescimento de receita.',
status: 'active'
},
{
id: 'u2',
tenant_id: 'tenant_123',
name: 'Alex Noer',
email: 'alex@fasto.com',
role: 'agent',
team_id: 'sales_1',
avatar_url: 'https://picsum.photos/id/1012/200/200',
bio: 'Melhor desempenho no Q3. Focado em clientes corporativos e relacionamentos de longo prazo.',
status: 'active'
},
{
id: 'u3',
tenant_id: 'tenant_123',
name: 'Angela Moss',
email: 'angela@fasto.com',
role: 'agent',
team_id: 'sales_1',
avatar_url: 'https://picsum.photos/id/1013/200/200',
status: 'inactive'
},
{
id: 'u4',
tenant_id: 'tenant_123',
name: 'Brian Samuel',
email: 'brian@fasto.com',
role: 'agent',
team_id: 'sales_2',
avatar_url: 'https://picsum.photos/id/1014/200/200',
status: 'active'
},
{
id: 'u5',
tenant_id: 'tenant_123',
name: 'Benny Chagur',
email: 'benny@fasto.com',
role: 'agent',
team_id: 'sales_2',
avatar_url: 'https://picsum.photos/id/1025/200/200',
status: 'active'
},
];
const generateMockAttendances = (count: number): Attendance[] => {
const origins = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'] as const;
const stages = Object.values(FunnelStage);
const products = ['Plano Premium', 'Plano Básico', 'Suíte Enterprise', 'Consultoria'];
return Array.from({ length: count }).map((_, i) => {
const user = USERS.slice(1)[Math.floor(Math.random() * (USERS.length - 1))]; // Skip super admin
const rand = Math.random();
// Weighted stages for realism
let stage = FunnelStage.IDENTIFICATION;
let isConverted = false;
if (rand > 0.85) {
stage = FunnelStage.WON;
isConverted = true;
} else if (rand > 0.7) {
stage = FunnelStage.LOST;
} else if (rand > 0.5) {
stage = FunnelStage.NEGOTIATION;
} else if (rand > 0.2) {
stage = FunnelStage.IDENTIFICATION;
} else {
stage = FunnelStage.NO_CONTACT;
}
// Force won/lost logic consistency
if (stage === FunnelStage.WON) isConverted = true;
return {
id: `att_${i}`,
tenant_id: 'tenant_123',
user_id: user.id,
created_at: new Date(Date.now() - Math.floor(Math.random() * 60 * 24 * 60 * 60 * 1000)).toISOString(),
summary: "Cliente perguntou sobre detalhes do produto e níveis de preços.",
attention_points: Math.random() > 0.8 ? ["Resposta demorada", "Verificar tom de voz"] : [],
improvement_points: ["Sugerir plano anual", "Fazer follow-up mais cedo"],
score: Math.floor(Math.random() * (100 - 50) + 50),
first_response_time_min: Math.floor(Math.random() * 120),
handling_time_min: Math.floor(Math.random() * 45),
funnel_stage: stage,
origin: origins[Math.floor(Math.random() * origins.length)],
product_requested: products[Math.floor(Math.random() * products.length)],
product_sold: isConverted ? products[Math.floor(Math.random() * products.length)] : undefined,
converted: isConverted,
};
});
};
export const MOCK_ATTENDANCES = generateMockAttendances(300);
// Visual Constants // Visual Constants
export const COLORS = { export const COLORS = {

View File

@@ -1,4 +1,4 @@
version: '3.8' version: "3.8"
services: services:
app: app:
@@ -43,12 +43,31 @@ services:
networks: networks:
- fasto-net - fasto-net
backup-mysql:
image: fradelg/mysql-cron-backup
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
MYSQL_HOST: db
MYSQL_USER: root
MYSQL_PASS: ${DB_PASSWORD:-root_password}
CRON_TIME: "55 2 * * *" # Roda todo dia exatamente às 02:55 da manhã
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
volumes:
- /opt/backups_db/fasto:/backup
networks:
- fasto-net
volumes: volumes:
db_data: db_data:
configs: configs:
init_sql: init_sql:
file: ./agenciac_comia.sql file: ./agenciac_comia.sql
name: init_sql_v2
networks: networks:
fasto-net: fasto-net:

198
pages/ApiKeys.tsx Normal file
View File

@@ -0,0 +1,198 @@
import React, { useState, useEffect } from 'react';
import { Key, Loader2, Plus, Trash2, Copy, CheckCircle2, Building2 } from 'lucide-react';
import { getApiKeys, createApiKey, deleteApiKey, getTenants } from '../services/dataService';
import { Tenant } from '../types';
export const ApiKeys: React.FC = () => {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState<string>('');
const [apiKeys, setApiKeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [newKeyName, setNewKeyName] = useState('');
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
useEffect(() => {
const fetchInitialData = async () => {
try {
const fetchedTenants = await getTenants();
setTenants(fetchedTenants);
if (fetchedTenants.length > 0) {
const defaultTenant = fetchedTenants.find(t => t.id !== 'system') || fetchedTenants[0];
setSelectedTenantId(defaultTenant.id);
}
} catch (err) {
console.error("Failed to load tenants", err);
} finally {
setLoading(false);
}
};
fetchInitialData();
}, []);
useEffect(() => {
if (selectedTenantId) {
loadApiKeys(selectedTenantId);
setGeneratedKey(null);
}
}, [selectedTenantId]);
const loadApiKeys = async (tenantId: string) => {
setLoading(true);
try {
const keys = await getApiKeys(tenantId);
setApiKeys(keys);
} catch (e) {
console.error("Failed to load API keys", e);
} finally {
setLoading(false);
}
};
const handleGenerateApiKey = async () => {
if (!newKeyName.trim() || !selectedTenantId) return;
setIsGeneratingKey(true);
try {
const res = await createApiKey({ name: newKeyName, tenantId: selectedTenantId });
setGeneratedKey(res.secret_key);
setNewKeyName('');
loadApiKeys(selectedTenantId);
} catch (err: any) {
alert(err.message || 'Erro ao gerar chave.');
} finally {
setIsGeneratingKey(false);
}
};
const handleRevokeApiKey = async (id: string) => {
if (!selectedTenantId) return;
if (confirm('Tem certeza? Todas as integrações usando esta chave pararão de funcionar imediatamente.')) {
const success = await deleteApiKey(id);
if (success) {
loadApiKeys(selectedTenantId);
}
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
alert('Chave copiada para a área de transferência!');
};
return (
<div className="max-w-4xl mx-auto space-y-6 pb-12 transition-colors duration-300">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Integrações via API</h1>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">Gerencie chaves de API para permitir que sistemas externos como o n8n se conectem a organizações específicas.</p>
</div>
</div>
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden p-6 md:p-8">
<div className="flex flex-col md:flex-row gap-6 mb-8">
<div className="flex-1">
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase tracking-wider mb-2 block">Selecione a Organização</label>
<div className="relative">
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
className="w-full appearance-none bg-zinc-50 dark:bg-dark-input border border-zinc-200 dark:border-dark-border px-4 py-3 pr-10 rounded-xl text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all cursor-pointer font-medium"
>
{tenants.filter(t => t.id !== 'system').map(t => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-4 text-zinc-500">
<Building2 size={16} />
</div>
</div>
</div>
</div>
{generatedKey && (
<div className="mb-8 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl animate-in fade-in zoom-in duration-200">
<h4 className="font-bold text-green-800 dark:text-green-300 flex items-center gap-2 mb-2">
<CheckCircle2 size={18} /> Chave Gerada com Sucesso!
</h4>
<p className="text-sm text-green-700 dark:text-green-400 mb-3">
Copie esta chave agora. Por motivos de segurança, ela <strong>não</strong> será exibida novamente.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white dark:bg-zinc-950 p-3 rounded-lg text-sm font-mono text-zinc-800 dark:text-zinc-200 border border-green-200 dark:border-green-800 break-all shadow-inner">
{generatedKey}
</code>
<button onClick={() => copyToClipboard(generatedKey)} className="p-3 bg-white dark:bg-zinc-950 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-100 dark:hover:bg-zinc-800 text-green-700 dark:text-green-400 transition-colors shadow-sm" title="Copiar">
<Copy size={18} />
</button>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<input
type="text"
placeholder="Nome da integração (ex: Webhook n8n - WhatsApp)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="flex-1 p-3 border border-zinc-200 dark:border-dark-border rounded-xl bg-zinc-50 dark:bg-dark-input text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-brand-yellow/20 focus:border-brand-yellow sm:text-sm transition-all"
/>
<button
onClick={handleGenerateApiKey}
disabled={isGeneratingKey || !newKeyName.trim() || !selectedTenantId}
className="px-6 py-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-xl font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0 shadow-sm"
>
{isGeneratingKey ? <Loader2 className="animate-spin" size={18} /> : <Plus size={18} />}
Gerar Nova Chave
</button>
</div>
{loading ? (
<div className="flex justify-center p-8"><Loader2 className="animate-spin text-zinc-400" size={32} /></div>
) : apiKeys.length > 0 ? (
<div className="overflow-x-auto rounded-xl border border-zinc-200 dark:border-dark-border">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-zinc-50/80 dark:bg-dark-bg/80 border-b border-zinc-200 dark:border-dark-border">
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Nome da Integração</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Chave (Mascarada)</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Último Uso</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border/50 bg-white dark:bg-dark-card">
{apiKeys.map(key => (
<tr key={key.id} className="hover:bg-zinc-50 dark:hover:bg-dark-input/50 transition-colors">
<td className="py-3 px-4 text-sm font-medium text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<Key size={14} className="text-brand-yellow" />
{key.name}
</td>
<td className="py-3 px-4 text-sm font-mono text-zinc-500 dark:text-zinc-400">{key.masked_key}</td>
<td className="py-3 px-4 text-xs text-zinc-500 dark:text-zinc-400">
{key.last_used_at ? new Date(key.last_used_at).toLocaleString('pt-BR') : 'Nunca'}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleRevokeApiKey(key.id)}
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors inline-flex"
title="Revogar Chave"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center p-12 border-2 border-dashed border-zinc-200 dark:border-dark-border rounded-xl text-zinc-500 dark:text-zinc-400 bg-zinc-50/30 dark:bg-dark-bg/30">
<Key size={32} className="mx-auto mb-3 opacity-20" />
<p className="font-medium text-zinc-600 dark:text-zinc-300">Nenhuma chave de API gerada</p>
<p className="text-xs mt-1">Crie uma nova chave para conectar sistemas externos a esta organização.</p>
</div>
)}
</div>
</div>
);
};

View File

@@ -98,7 +98,7 @@ export const AttendanceDetail: React.FC = () => {
</span> </span>
</div> </div>
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight"> <h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
{data.summary} {data.title}
</h1> </h1>
{agent && ( {agent && (
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
@@ -136,11 +136,17 @@ export const AttendanceDetail: React.FC = () => {
<MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" /> <MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" />
Resumo da Interação Resumo da Interação
</h3> </h3>
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm"> <div className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap">
{data.summary} O cliente perguntou sobre detalhes específicos relacionados ao <span className="font-medium text-zinc-800 dark:text-zinc-100">{data.product_requested}</span>. {data.full_summary ? (
As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume. data.full_summary
A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}. ) : (
</p> <>
{data.title} O cliente perguntou sobre detalhes específicos relacionados ao <span className="font-medium text-zinc-800 dark:text-zinc-100">{data.product_requested}</span>.
As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume.
A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}.
</>
)}
</div>
</div> </div>
{/* Feedback Section */} {/* Feedback Section */}

View File

@@ -5,9 +5,9 @@ import {
import { import {
Users, Clock, Phone, TrendingUp, Filter Users, Clock, Phone, TrendingUp, Filter
} from 'lucide-react'; } from 'lucide-react';
import { getAttendances, getUsers, getTeams, getUserById, getFunnels } from '../services/dataService'; import { getAttendances, getUsers, getTeams, getUserById, getFunnels, getOrigins } from '../services/dataService';
import { COLORS } from '../constants'; import { COLORS } from '../constants';
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef } from '../types'; import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types';
import { KPICard } from '../components/KPICard'; import { KPICard } from '../components/KPICard';
import { DateRangePicker } from '../components/DateRangePicker'; import { DateRangePicker } from '../components/DateRangePicker';
import { SellersTable } from '../components/SellersTable'; import { SellersTable } from '../components/SellersTable';
@@ -29,6 +29,7 @@ export const Dashboard: React.FC = () => {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]); const [teams, setTeams] = useState<any[]>([]);
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]); const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [originDefs, setOriginDefs] = useState<OriginItemDef[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [filters, setFilters] = useState<DashboardFilter>({ const [filters, setFilters] = useState<DashboardFilter>({
@@ -57,12 +58,13 @@ export const Dashboard: React.FC = () => {
const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } }; const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } };
// Fetch users, attendances, teams, funnels and current user in parallel // Fetch users, attendances, teams, funnels and current user in parallel
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, me] = await Promise.all([ const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, fetchedOrigins, me] = await Promise.all([
getUsers(tenantId), getUsers(tenantId),
getAttendances(tenantId, filters), getAttendances(tenantId, filters),
getAttendances(tenantId, prevFilters), getAttendances(tenantId, prevFilters),
getTeams(tenantId), getTeams(tenantId),
getFunnels(tenantId), getFunnels(tenantId),
getOrigins(tenantId),
storedUserId ? getUserById(storedUserId) : null storedUserId ? getUserById(storedUserId) : null
]); ]);
@@ -70,6 +72,7 @@ export const Dashboard: React.FC = () => {
setData(fetchedData); setData(fetchedData);
setPrevData(prevFetchedData); setPrevData(prevFetchedData);
setTeams(fetchedTeams); setTeams(fetchedTeams);
setOriginDefs(fetchedOrigins);
if (me) setCurrentUser(me); if (me) setCurrentUser(me);
// Determine which funnel to display // Determine which funnel to display
@@ -82,6 +85,14 @@ export const Dashboard: React.FC = () => {
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []); setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []);
// Determine which origins to display
let activeOriginGroup = fetchedOrigins[0];
if (targetTeamId) {
const matchedOrigin = fetchedOrigins.find(o => o.teamIds?.includes(targetTeamId));
if (matchedOrigin) activeOriginGroup = matchedOrigin;
}
setOriginDefs(activeOriginGroup && activeOriginGroup.items ? activeOriginGroup.items : []);
} catch (error) { } catch (error) {
console.error("Error loading dashboard data:", error); console.error("Error loading dashboard data:", error);
} finally { } finally {
@@ -168,18 +179,59 @@ export const Dashboard: React.FC = () => {
})); }));
}, [data, funnelDefs]); }, [data, funnelDefs]);
const tailwindToHex: Record<string, string> = {
'zinc': '#71717a',
'blue': '#3b82f6',
'purple': '#a855f7',
'green': '#22c55e',
'red': '#ef4444',
'pink': '#ec4899',
'orange': '#f97316',
'yellow': '#eab308'
};
// --- Chart Data: Origin --- // --- Chart Data: Origin ---
const originData = useMemo(() => { const originData = useMemo(() => {
const origins = data.reduce((acc, curr) => { const counts = data.reduce((acc, curr) => {
acc[curr.origin] = (acc[curr.origin] || 0) + 1; acc[curr.origin] = (acc[curr.origin] || 0) + 1;
return acc; return acc;
}, {} as Record<string, number>); }, {} as Record<string, number>);
// Ensure type safety for value in sort if (originDefs.length > 0) {
return (Object.entries(origins) as [string, number][]) const activeOrigins = originDefs.map(def => {
.map(([name, value]) => ({ name, value })) let hexColor = '#71717a'; // Default zinc
.sort((a, b) => b.value - a.value); if (def.color_class) {
}, [data]); const match = def.color_class.match(/bg-([a-z]+)-\d+/);
if (match && tailwindToHex[match[1]]) {
hexColor = tailwindToHex[match[1]];
}
}
return {
name: def.name,
value: counts[def.name] || 0,
hexColor
};
});
// Calculate "Outros" for data that doesn't match current active origins
const activeNames = new Set(originDefs.map(d => d.name));
const othersValue = (Object.entries(counts) as [string, number][])
.filter(([name]) => !activeNames.has(name))
.reduce((sum, [_, val]) => sum + val, 0);
if (othersValue > 0) {
activeOrigins.push({
name: 'Outros',
value: othersValue,
hexColor: '#94a3b8' // Gray-400
});
}
return activeOrigins.sort((a, b) => b.value - a.value);
}
return []; // No definitions = No chart (matches funnel behavior)
}, [data, originDefs]);
// --- Table Data: Sellers Ranking --- // --- Table Data: Sellers Ranking ---
const sellersRanking = useMemo(() => { const sellersRanking = useMemo(() => {
@@ -299,19 +351,18 @@ export const Dashboard: React.FC = () => {
<option key={stage} value={stage}>{stage}</option> <option key={stage} value={stage}>{stage}</option>
))} ))}
</select> </select>
<select <select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all" className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
value={filters.origin} value={filters.origin}
onChange={(e) => handleFilterChange('origin', e.target.value)} onChange={(e) => handleFilterChange('origin', e.target.value)}
> >
<option value="all">Todas Origens</option> <option value="all">Todas Origens</option>
<option value="WhatsApp">WhatsApp</option> {originDefs.length > 0 ? originDefs.map(o => (
<option value="Instagram">Instagram</option> <option key={o.id} value={o.name}>{o.name}</option>
<option value="Website">Website</option> )) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => (
<option value="LinkedIn">LinkedIn</option> <option key={o} value={o}>{o}</option>
<option value="Indicação">Indicação</option> ))}
</select> </select> </div>
</div>
</div> </div>
</div> </div>
@@ -417,12 +468,11 @@ export const Dashboard: React.FC = () => {
stroke="none" stroke="none"
> >
{originData.map((entry, index) => ( {originData.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={COLORS.origins[entry.name as keyof typeof COLORS.origins] || COLORS.charts[index % COLORS.charts.length]} fill={entry.hexColor || COLORS.charts[index % COLORS.charts.length]}
/> />
))} ))} </Pie>
</Pie>
<Tooltip <Tooltip
formatter={(value: any) => [value, 'Leads']} formatter={(value: any) => [value, 'Leads']}
contentStyle={{ contentStyle={{

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { login, logout } from '../services/dataService'; import { login, logout } from '../services/dataService';
@@ -12,6 +12,16 @@ export const Login: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [emailError, setEmailError] = useState(''); const [emailError, setEmailError] = useState('');
// Auto-redirect if already logged in
useEffect(() => {
const token = localStorage.getItem('ctms_token');
const userId = localStorage.getItem('ctms_user_id');
if (token && userId && token !== 'undefined' && token !== 'null') {
// Opt to send them to root, AuthGuard will handle role-based routing
navigate('/');
}
}, [navigate]);
const validateEmail = (val: string) => { const validateEmail = (val: string) => {
setEmail(val); setEmail(val);
if (!val) { if (!val) {

332
pages/Origins.tsx Normal file
View File

@@ -0,0 +1,332 @@
import React, { useState, useEffect } from 'react';
import { Target, Plus, Edit, Trash2, Loader2, X, Users } from 'lucide-react';
import { getOrigins, createOriginGroup, updateOriginGroup, deleteOriginGroup, createOriginItem, updateOriginItem, deleteOriginItem, getTeams } from '../services/dataService';
import { OriginGroupDef, OriginItemDef } from '../types';
export const Origins: React.FC = () => {
const [originGroups, setOriginGroups] = useState<OriginGroupDef[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<OriginItemDef | null>(null);
const [formData, setFormData] = useState({ name: '', color_class: '' });
const [isSaving, setIsSaving] = useState(false);
// Group creation state
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [groupName, setGroupName] = useState('');
const tenantId = localStorage.getItem('ctms_tenant_id') || '';
const PRESET_COLORS = [
{ label: 'Cinza', value: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border' },
{ label: 'Azul', value: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
{ label: 'Roxo', value: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800' },
{ label: 'Verde', value: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
{ label: 'Vermelho', value: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
{ label: 'Rosa', value: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
];
const loadData = async () => {
setLoading(true);
const [fetchedGroups, fetchedTeams] = await Promise.all([
getOrigins(tenantId),
getTeams(tenantId)
]);
setOriginGroups(fetchedGroups);
setTeams(fetchedTeams);
if (!selectedGroupId && fetchedGroups.length > 0) {
setSelectedGroupId(fetchedGroups[0].id);
}
setLoading(false);
};
useEffect(() => {
loadData();
}, [tenantId]);
const selectedGroup = originGroups.find(g => g.id === selectedGroupId);
// --- Group Handlers ---
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const res = await createOriginGroup({ name: groupName, tenantId });
setSelectedGroupId(res.id);
setIsGroupModalOpen(false);
setGroupName('');
loadData();
} catch (err) {
alert("Erro ao criar grupo de origens.");
} finally {
setIsSaving(false);
}
};
const handleDeleteGroup = async (id: string) => {
if (originGroups.length <= 1) {
alert("Você precisa ter pelo menos um grupo de origens ativo.");
return;
}
if (confirm('Tem certeza que deseja excluir este grupo e todas as suas origens?')) {
await deleteOriginGroup(id);
setSelectedGroupId(null);
loadData();
}
};
const handleToggleTeam = async (teamId: string) => {
if (!selectedGroup) return;
const currentTeamIds = selectedGroup.teamIds || [];
const newTeamIds = currentTeamIds.includes(teamId)
? currentTeamIds.filter(id => id !== teamId)
: [...currentTeamIds, teamId];
// Optimistic
const newGroups = [...originGroups];
const idx = newGroups.findIndex(g => g.id === selectedGroup.id);
newGroups[idx].teamIds = newTeamIds;
setOriginGroups(newGroups);
await updateOriginGroup(selectedGroup.id, { teamIds: newTeamIds });
};
// --- Item Handlers ---
const handleSaveItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedGroup) return;
setIsSaving(true);
try {
if (editingItem) {
await updateOriginItem(editingItem.id, formData);
} else {
await createOriginItem(selectedGroup.id, formData);
}
setIsModalOpen(false);
loadData();
} catch (err: any) {
alert(err.message || "Erro ao salvar origem.");
} finally {
setIsSaving(false);
}
};
const handleDeleteItem = async (id: string) => {
if (confirm('Tem certeza que deseja excluir esta origem?')) {
await deleteOriginItem(id);
loadData();
}
};
const openItemModal = (item?: OriginItemDef) => {
if (item) {
setEditingItem(item);
setFormData({ name: item.name, color_class: item.color_class || PRESET_COLORS[0].value });
} else {
setEditingItem(null);
setFormData({ name: '', color_class: PRESET_COLORS[0].value });
}
setIsModalOpen(true);
};
if (loading && originGroups.length === 0) return <div className="p-12 flex justify-center"><Loader2 className="animate-spin text-zinc-400" size={32} /></div>;
return (
<div className="max-w-6xl mx-auto space-y-6 pb-12 transition-colors duration-300 flex flex-col md:flex-row gap-8">
{/* Sidebar: Groups List */}
<div className="w-full md:w-64 shrink-0 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-zinc-900 dark:text-zinc-50">Configurações</h2>
<button onClick={() => setIsGroupModalOpen(true)} className="p-1.5 bg-zinc-100 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-lg hover:bg-zinc-200 dark:hover:bg-dark-border transition-colors">
<Plus size={16} />
</button>
</div>
<div className="flex flex-col gap-2">
{originGroups.map(g => (
<button
key={g.id}
onClick={() => setSelectedGroupId(g.id)}
className={`text-left px-4 py-3 rounded-xl text-sm font-medium transition-all ${selectedGroupId === g.id ? 'bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 shadow-md' : 'bg-white dark:bg-dark-card text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-dark-bg border border-zinc-200 dark:border-dark-border'}`}
>
<div className="flex justify-between items-center">
<span className="truncate pr-2">{g.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full shrink-0 ${selectedGroupId === g.id ? 'bg-white/20 dark:bg-black/20' : 'bg-zinc-100 dark:bg-dark-bg'}`}>{g.items?.length || 0}</span>
</div>
</button>
))}
</div>
</div>
{/* Main Content: Selected Group Details */}
<div className="flex-1 space-y-6">
{selectedGroup ? (
<>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 border-b border-zinc-200 dark:border-dark-border pb-6">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">{selectedGroup.name}</h1>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">Gerencie as origens deste grupo e quais times as utilizam.</p>
</div>
<button onClick={() => handleDeleteGroup(selectedGroup.id)} className="text-red-500 hover:text-red-700 bg-red-50 dark:bg-red-900/20 px-3 py-2 rounded-lg text-sm font-semibold transition-colors flex items-center gap-2">
<Trash2 size={16} /> Excluir Grupo
</button>
</div>
{/* Teams Assignment */}
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-bg/50">
<h3 className="font-bold text-zinc-900 dark:text-zinc-50 flex items-center gap-2">
<Users className="text-brand-yellow" size={18} /> Times Atribuídos
</h3>
</div>
<div className="p-6">
{teams.length === 0 ? (
<p className="text-sm text-zinc-500">Nenhum time cadastrado na organização.</p>
) : (
<div className="flex flex-wrap gap-3">
{teams.map(t => {
const isAssigned = selectedGroup.teamIds?.includes(t.id);
return (
<button
key={t.id}
onClick={() => handleToggleTeam(t.id)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all ${isAssigned ? 'bg-brand-yellow/10 border-brand-yellow text-zinc-900 dark:text-zinc-100' : 'bg-white dark:bg-dark-input border-zinc-200 dark:border-dark-border text-zinc-500 dark:text-zinc-400 hover:border-zinc-300 dark:hover:border-zinc-700'}`}
>
{t.name}
</button>
);
})}
</div>
)}
<p className="text-xs text-zinc-400 mt-4">Times não atribuídos a um grupo específico usarão o grupo padrão.</p>
</div>
</div>
{/* Origin Items */}
<div className="bg-white dark:bg-dark-card rounded-2xl border border-zinc-200 dark:border-dark-border shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-bg/50 flex justify-between items-center">
<h3 className="font-bold text-zinc-900 dark:text-zinc-50 flex items-center gap-2">
<Target className="text-brand-yellow" size={18} /> Fontes de Tráfego
</h3>
<button onClick={() => openItemModal()} className="text-sm font-bold text-brand-yellow hover:underline flex items-center gap-1">
<Plus size={16} /> Nova Origem
</button>
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{selectedGroup.items?.map((o) => (
<div key={o.id} className="p-4 px-6 flex items-center justify-between group hover:bg-zinc-50 dark:hover:bg-dark-bg transition-colors">
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${o.color_class || 'bg-zinc-100 text-zinc-700 border-zinc-200'}`}>
{o.name}
</span>
<div className="flex gap-2">
<button onClick={() => openItemModal(o)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-100 dark:hover:bg-dark-input rounded-lg transition-colors">
<Edit size={16} />
</button>
<button onClick={() => handleDeleteItem(o.id)} className="p-2 text-zinc-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors">
<Trash2 size={16} />
</button>
</div>
</div>
))}
{(!selectedGroup.items || selectedGroup.items.length === 0) && (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted">Nenhuma origem configurada neste grupo.</div>
)}
</div>
</div>
</>
) : (
<div className="p-12 text-center text-zinc-500">Selecione ou crie um grupo de origens.</div>
)}
</div>
{/* Group Creation Modal */}
{isGroupModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div className="bg-white dark:bg-dark-card rounded-xl shadow-xl w-full max-w-sm overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
<h3 className="font-bold text-zinc-900 dark:text-zinc-50">Novo Grupo</h3>
<button onClick={() => setIsGroupModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
</div>
<form onSubmit={handleCreateGroup} className="p-6 space-y-4">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Nome do Grupo</label>
<input
type="text"
value={groupName}
onChange={e => setGroupName(e.target.value)}
placeholder="Ex: Origens B2B"
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all"
required
/>
</div>
<div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6">
<button type="button" onClick={() => setIsGroupModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
<button type="submit" disabled={isSaving || !groupName.trim()} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70">
{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Criar Grupo'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Item Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div className="bg-white dark:bg-dark-card rounded-xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-zinc-100 dark:border-dark-border flex justify-between items-center bg-zinc-50/50 dark:bg-dark-bg/50">
<h3 className="font-bold text-zinc-900 dark:text-zinc-50">{editingItem ? 'Editar Origem' : 'Nova Origem'}</h3>
<button onClick={() => setIsModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
</div>
<form onSubmit={handleSaveItem} className="p-6 space-y-4">
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-1 block">Nome da Origem</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="Ex: Facebook Ads"
className="w-full bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border p-3 rounded-lg text-sm text-zinc-900 dark:text-zinc-100 focus:ring-2 focus:ring-brand-yellow/20 outline-none transition-all"
required
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-2 block">Cor Visual</label>
<div className="grid grid-cols-2 gap-2">
{PRESET_COLORS.map((color, i) => (
<label key={i} className={`flex items-center gap-2 p-2 border rounded-lg cursor-pointer transition-all ${formData.color_class === color.value ? 'border-brand-yellow bg-yellow-50/50 dark:bg-yellow-900/10' : 'border-zinc-200 dark:border-dark-border hover:bg-zinc-50 dark:hover:bg-dark-input'}`}>
<input
type="radio"
name="color"
value={color.value}
checked={formData.color_class === color.value}
onChange={(e) => setFormData({...formData, color_class: e.target.value})}
className="sr-only"
/>
<span className={`w-3 h-3 rounded-full border ${color.value.split(' ')[0]} ${color.value.split(' ')[2]}`}></span>
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{color.label}</span>
</label>
))}
</div>
</div>
<div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
<button type="submit" disabled={isSaving} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70">
{isSaving ? <Loader2 className="animate-spin" size={16} /> : 'Salvar Origem'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { getAttendances, getUserById, getFunnels } from '../services/dataService'; import { getAttendances, getUserById, getFunnels, getOrigins } from '../services/dataService';
import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef } from '../types'; import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef, OriginItemDef } from '../types';
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react'; import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react';
import { DateRangePicker } from '../components/DateRangePicker'; import { DateRangePicker } from '../components/DateRangePicker';
@@ -12,6 +12,7 @@ export const UserDetail: React.FC = () => {
const [user, setUser] = useState<User | undefined>(); const [user, setUser] = useState<User | undefined>();
const [attendances, setAttendances] = useState<Attendance[]>([]); const [attendances, setAttendances] = useState<Attendance[]>([]);
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]); const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [originDefs, setOriginDefs] = useState<OriginItemDef[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<DashboardFilter>({ const [filters, setFilters] = useState<DashboardFilter>({
@@ -32,12 +33,13 @@ export const UserDetail: React.FC = () => {
setUser(u); setUser(u);
if (u && tenantId) { if (u && tenantId) {
const [data, fetchedFunnels] = await Promise.all([ const [data, fetchedFunnels, fetchedOrigins] = await Promise.all([
getAttendances(tenantId, { getAttendances(tenantId, {
...filters, ...filters,
userId: id userId: id
}), }),
getFunnels(tenantId) getFunnels(tenantId),
getOrigins(tenantId)
]); ]);
setAttendances(data); setAttendances(data);
@@ -48,6 +50,13 @@ export const UserDetail: React.FC = () => {
if (matchedFunnel) activeFunnel = matchedFunnel; if (matchedFunnel) activeFunnel = matchedFunnel;
} }
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []); setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []);
let activeOriginGroup = fetchedOrigins[0];
if (targetTeamId) {
const matchedOrigin = fetchedOrigins.find(o => o.teamIds?.includes(targetTeamId));
if (matchedOrigin) activeOriginGroup = matchedOrigin;
}
setOriginDefs(activeOriginGroup && activeOriginGroup.items ? activeOriginGroup.items : []);
} }
} catch (error) { } catch (error) {
console.error("Error loading user details", error); console.error("Error loading user details", error);
@@ -154,19 +163,18 @@ export const UserDetail: React.FC = () => {
<option key={stage} value={stage}>{stage}</option> <option key={stage} value={stage}>{stage}</option>
))} ))}
</select> </select>
<select <select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 transition-all" className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-zinc-700 transition-all"
value={filters.origin} value={filters.origin}
onChange={(e) => handleFilterChange('origin', e.target.value)} onChange={(e) => handleFilterChange('origin', e.target.value)}
> >
<option value="all">Todas Origens</option> <option value="all">Todas Origens</option>
<option value="WhatsApp">WhatsApp</option> {originDefs.length > 0 ? originDefs.map(o => (
<option value="Instagram">Instagram</option> <option key={o.id} value={o.name}>{o.name}</option>
<option value="Website">Website</option> )) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => (
<option value="LinkedIn">LinkedIn</option> <option key={o} value={o}>{o}</option>
<option value="Indicação">Indicação</option> ))}
</select> </select> </div>
</div>
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -214,15 +222,13 @@ export const UserDetail: React.FC = () => {
{currentData.map(att => ( {currentData.map(att => (
<tr key={att.id} className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50 transition-colors group"> <tr key={att.id} className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50 transition-colors group">
<td className="px-6 py-4 text-zinc-600 dark:text-zinc-300 whitespace-nowrap"> <td className="px-6 py-4 text-zinc-600 dark:text-zinc-300 whitespace-nowrap">
<div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString()}</div> <div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString('pt-BR')}</div>
<div className="text-xs text-zinc-400 dark:text-dark-muted">{new Date(att.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div> <div className="text-xs text-zinc-400 dark:text-dark-muted">{new Date(att.created_at).toLocaleTimeString('pt-BR', {hour: '2-digit', minute:'2-digit', hour12: false})}</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex flex-col"> <span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.title}</span>
<span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.summary}</span> <div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-dark-muted">
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-dark-muted"> <span className="flex items-center gap-1"><MessageSquare size={10} /> {att.origin}</span>
<span className="flex items-center gap-1"><MessageSquare size={10} /> {att.origin}</span>
</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell, Key, Trash2, Copy, Plus } from 'lucide-react'; import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar, getApiKeys, createApiKey, deleteApiKey } from '../services/dataService'; import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
import { User, Tenant } from '../types'; import { User, Tenant } from '../types';
export const UserProfile: React.FC = () => { export const UserProfile: React.FC = () => {
@@ -16,25 +16,9 @@ export const UserProfile: React.FC = () => {
const [bio, setBio] = useState(''); const [bio, setBio] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
// API Keys state
const [apiKeys, setApiKeys] = useState<any[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
const fetchApiKeys = async (tenantId: string) => {
try {
const keys = await getApiKeys(tenantId);
setApiKeys(keys);
} catch (e) {
console.error("Failed to load API keys", e);
}
};
useEffect(() => { useEffect(() => {
const fetchUserAndTenant = async () => { const fetchUserAndTenant = async () => {
const storedUserId = localStorage.getItem('ctms_user_id'); const storedUserId = localStorage.getItem('ctms_user_id');
const storedTenantId = localStorage.getItem('ctms_tenant_id');
if (storedUserId) { if (storedUserId) {
try { try {
@@ -45,12 +29,6 @@ export const UserProfile: React.FC = () => {
setBio(fetchedUser.bio || ''); setBio(fetchedUser.bio || '');
setEmail(fetchedUser.email); setEmail(fetchedUser.email);
if (fetchedUser.role === 'admin' || fetchedUser.role === 'super_admin') {
if (storedTenantId) {
fetchApiKeys(storedTenantId);
}
}
// Fetch tenant info // Fetch tenant info
const tenants = await getTenants(); const tenants = await getTenants();
const userTenant = tenants.find(t => t.id === fetchedUser.tenant_id); const userTenant = tenants.find(t => t.id === fetchedUser.tenant_id);
@@ -72,36 +50,6 @@ export const UserProfile: React.FC = () => {
fetchUserAndTenant(); fetchUserAndTenant();
}, []); }, []);
const handleGenerateApiKey = async () => {
if (!newKeyName.trim() || !tenant) return;
setIsGeneratingKey(true);
try {
const res = await createApiKey({ name: newKeyName, tenantId: tenant.id });
setGeneratedKey(res.secret_key);
setNewKeyName('');
fetchApiKeys(tenant.id);
} catch (err: any) {
alert(err.message || 'Erro ao gerar chave.');
} finally {
setIsGeneratingKey(false);
}
};
const handleRevokeApiKey = async (id: string) => {
if (!tenant) return;
if (confirm('Tem certeza? Todas as integrações usando esta chave pararão de funcionar imediatamente.')) {
const success = await deleteApiKey(id);
if (success) {
fetchApiKeys(tenant.id);
}
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
alert('Chave copiada para a área de transferência!');
};
const handleAvatarClick = () => { const handleAvatarClick = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
@@ -254,9 +202,17 @@ export const UserProfile: React.FC = () => {
id="fullName" id="fullName"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400 transition-all sm:text-sm" disabled={user.role === 'agent'}
className={`block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg sm:text-sm transition-all ${
user.role === 'agent'
? 'bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed'
: 'bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400'
}`}
/> />
</div> </div>
{user.role === 'agent' && (
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate um administrador para alterar seu nome.</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -373,104 +329,6 @@ export const UserProfile: React.FC = () => {
</form> </form>
</div> </div>
</div> </div>
{/* API Keys Section (Only for Admins/Super Admins) */}
{(user.role === 'admin' || user.role === 'super_admin') && (
<div className="lg:col-span-3">
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 overflow-hidden transition-colors">
<div className="p-6 md:p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-xl">
<Key size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Integrações via API (n8n, etc)</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie as chaves de acesso para que sistemas externos possam enviar dados (como atendimentos) para sua organização.
</p>
</div>
</div>
{generatedKey && (
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl">
<h4 className="font-bold text-green-800 dark:text-green-300 flex items-center gap-2 mb-2">
<CheckCircle2 size={18} /> Chave Gerada com Sucesso!
</h4>
<p className="text-sm text-green-700 dark:text-green-400 mb-3">
Copie esta chave agora. Por motivos de segurança, ela <strong>não</strong> será exibida novamente.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white dark:bg-zinc-950 p-3 rounded-lg text-sm font-mono text-zinc-800 dark:text-zinc-200 border border-green-200 dark:border-green-800 break-all">
{generatedKey}
</code>
<button onClick={() => copyToClipboard(generatedKey)} className="p-3 bg-white dark:bg-zinc-950 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-100 dark:hover:bg-zinc-800 text-green-700 dark:text-green-400 transition-colors" title="Copiar">
<Copy size={18} />
</button>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<input
type="text"
placeholder="Nome da integração (ex: n8n WhatsApp)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="flex-1 p-3 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-brand-yellow/20 focus:border-brand-yellow sm:text-sm transition-all"
/>
<button
onClick={handleGenerateApiKey}
disabled={isGeneratingKey || !newKeyName.trim()}
className="px-6 py-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0"
>
{isGeneratingKey ? <Loader2 className="animate-spin" size={18} /> : <Plus size={18} />}
Gerar Chave
</button>
</div>
{apiKeys.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-800">
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Nome</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Chave</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Último Uso</th>
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800/50">
{apiKeys.map(key => (
<tr key={key.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="py-3 px-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">{key.name}</td>
<td className="py-3 px-4 text-sm font-mono text-zinc-500 dark:text-zinc-400">{key.masked_key}</td>
<td className="py-3 px-4 text-xs text-zinc-500 dark:text-zinc-400">
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Nunca'}
</td>
<td className="py-3 px-4 text-right">
<button
onClick={() => handleRevokeApiKey(key.id)}
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors inline-flex"
title="Revogar Chave"
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center p-8 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-500 dark:text-zinc-400">
<Key size={32} className="mx-auto mb-3 opacity-20" />
Nenhuma chave de API gerada.
</div>
)}
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types';
// Em desenvolvimento, aponta para o localhost:3001 // Em desenvolvimento, aponta para o localhost:3001
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api'; const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
const getHeaders = () => { const getHeaders = (customToken?: string) => {
const token = localStorage.getItem('ctms_token'); const token = customToken || localStorage.getItem('ctms_token');
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido // Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' }; if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
@@ -17,9 +17,76 @@ const getHeaders = () => {
}; };
}; };
// Global flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
const onRefreshed = (token: string) => {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
};
const addRefreshSubscriber = (cb: (token: string) => void) => {
refreshSubscribers.push(cb);
};
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let response = await fetch(url, options);
// If unauthorized, attempt to refresh the token
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
const refreshToken = localStorage.getItem('ctms_refresh_token');
if (!refreshToken) {
logout();
return response;
}
if (isRefreshing) {
// If a refresh is already in progress, wait for it to finish and retry
return new Promise(resolve => {
addRefreshSubscriber((newToken) => {
options.headers = getHeaders(newToken);
resolve(fetch(url, options));
});
});
}
isRefreshing = true;
try {
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (!refreshResponse.ok) {
throw new Error('Refresh token invalid');
}
const data = await refreshResponse.json();
localStorage.setItem('ctms_token', data.token);
// Retry the original request
options.headers = getHeaders(data.token);
response = await fetch(url, options);
onRefreshed(data.token);
} catch (err) {
console.error("Session expired or refresh failed:", err);
logout();
} finally {
isRefreshing = false;
}
}
return response;
};
export const getNotifications = async (): Promise<any[]> => { export const getNotifications = async (): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/notifications`, { const response = await apiFetch(`${API_URL}/notifications`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Failed to fetch notifications'); if (!response.ok) throw new Error('Failed to fetch notifications');
@@ -32,7 +99,7 @@ export const getNotifications = async (): Promise<any[]> => {
export const markNotificationAsRead = async (id: string): Promise<boolean> => { export const markNotificationAsRead = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/${id}`, { const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders() headers: getHeaders()
}); });
@@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise<boolean> => {
export const markAllNotificationsAsRead = async (): Promise<boolean> => { export const markAllNotificationsAsRead = async (): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/read-all`, { const response = await apiFetch(`${API_URL}/notifications/read-all`, {
method: 'PUT', method: 'PUT',
headers: getHeaders() headers: getHeaders()
}); });
@@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
export const deleteNotification = async (id: string): Promise<boolean> => { export const deleteNotification = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/${id}`, { const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise<boolean> => {
export const clearAllNotifications = async (): Promise<boolean> => { export const clearAllNotifications = async (): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/notifications/clear-all`, { const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -85,7 +152,7 @@ export const clearAllNotifications = async (): Promise<boolean> => {
// --- Funnels Functions --- // --- Funnels Functions ---
export const getFunnels = async (tenantId: string): Promise<any[]> => { export const getFunnels = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/funnels?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar funis'); if (!response.ok) throw new Error('Falha ao buscar funis');
@@ -97,7 +164,7 @@ export const getFunnels = async (tenantId: string): Promise<any[]> => {
}; };
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => { export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await fetch(`${API_URL}/funnels`, { const response = await apiFetch(`${API_URL}/funnels`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -111,7 +178,7 @@ export const createFunnel = async (data: { name: string, tenantId: string }): Pr
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => { export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnels/${id}`, { const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -125,7 +192,7 @@ export const updateFunnel = async (id: string, data: { name?: string, teamIds?:
export const deleteFunnel = async (id: string): Promise<boolean> => { export const deleteFunnel = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnels/${id}`, { const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -137,7 +204,7 @@ export const deleteFunnel = async (id: string): Promise<boolean> => {
}; };
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => { export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
const response = await fetch(`${API_URL}/funnels/${funnelId}/stages`, { const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -151,7 +218,7 @@ export const createFunnelStage = async (funnelId: string, data: any): Promise<an
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => { export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnel_stages/${id}`, { const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -165,7 +232,7 @@ export const updateFunnelStage = async (id: string, data: any): Promise<boolean>
export const deleteFunnelStage = async (id: string): Promise<boolean> => { export const deleteFunnelStage = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/funnel_stages/${id}`, { const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -176,10 +243,104 @@ export const deleteFunnelStage = async (id: string): Promise<boolean> => {
} }
}; };
// --- Origins Functions ---
export const getOrigins = async (tenantId: string): Promise<any[]> => {
try {
const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Falha ao buscar origens');
return await response.json();
} catch (error) {
console.error("API Error (getOrigins):", error);
return [];
}
};
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await apiFetch(`${API_URL}/origins`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar grupo de origens');
}
return await response.json();
};
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/origins/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response.ok;
} catch (error) {
console.error("API Error (updateOriginGroup):", error);
return false;
}
};
export const deleteOriginGroup = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/origins/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteOriginGroup):", error);
return false;
}
};
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar item de origem');
}
return await response.json();
};
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response.ok;
} catch (error) {
console.error("API Error (updateOriginItem):", error);
return false;
}
};
export const deleteOriginItem = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteOriginItem):", error);
return false;
}
};
// --- API Keys Functions --- // --- API Keys Functions ---
export const getApiKeys = async (tenantId: string): Promise<any[]> => { export const getApiKeys = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/api-keys?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar chaves'); if (!response.ok) throw new Error('Falha ao buscar chaves');
@@ -191,7 +352,7 @@ export const getApiKeys = async (tenantId: string): Promise<any[]> => {
}; };
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => { export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await fetch(`${API_URL}/api-keys`, { const response = await apiFetch(`${API_URL}/api-keys`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -205,7 +366,7 @@ export const createApiKey = async (data: { name: string, tenantId: string }): Pr
export const deleteApiKey = async (id: string): Promise<boolean> => { export const deleteApiKey = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/api-keys/${id}`, { const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -218,7 +379,7 @@ export const deleteApiKey = async (id: string): Promise<boolean> => {
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => { export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
try { try {
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, { const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Search failed'); if (!response.ok) throw new Error('Search failed');
@@ -242,7 +403,7 @@ export const getAttendances = async (tenantId: string, filter: DashboardFilter):
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage); if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin); if (filter.origin && filter.origin !== 'all') params.append('origin', filter.origin);
const response = await fetch(`${API_URL}/attendances?${params.toString()}`, { const response = await apiFetch(`${API_URL}/attendances?${params.toString()}`, {
headers: getHeaders() headers: getHeaders()
}); });
@@ -263,7 +424,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tenantId !== 'all') params.append('tenantId', tenantId); if (tenantId !== 'all') params.append('tenantId', tenantId);
const response = await fetch(`${API_URL}/users?${params.toString()}`, { const response = await apiFetch(`${API_URL}/users?${params.toString()}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar usuários'); if (!response.ok) throw new Error('Falha ao buscar usuários');
@@ -277,25 +438,29 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
export const getUserById = async (id: string): Promise<User | undefined> => { export const getUserById = async (id: string): Promise<User | undefined> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) return undefined; if (!response.ok) {
if (response.status === 401 || response.status === 403 || response.status === 404) {
return undefined; // Invalid user or token
}
throw new Error(`Server error: ${response.status}`);
}
const contentType = response.headers.get("content-type"); const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) { if (contentType && contentType.indexOf("application/json") !== -1) {
return await response.json(); return await response.json();
} }
return undefined;
} catch (error) { } catch (error) {
console.error("API Error (getUserById):", error); console.error("API Error (getUserById):", error);
return undefined; throw error; // Rethrow so AuthGuard catches it and doesn't wipe tokens
} }
}; };
export const updateUser = async (id: string, userData: any): Promise<boolean> => { export const updateUser = async (id: string, userData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -319,7 +484,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
formData.append('avatar', file); formData.append('avatar', file);
const token = localStorage.getItem('ctms_token'); const token = localStorage.getItem('ctms_token');
const response = await fetch(`${API_URL}/users/${id}/avatar`, { const response = await apiFetch(`${API_URL}/users/${id}/avatar`, {
method: 'POST', method: 'POST',
headers: { headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {}) ...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -338,7 +503,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
export const createMember = async (userData: any): Promise<boolean> => { export const createMember = async (userData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users`, { const response = await apiFetch(`${API_URL}/users`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -358,7 +523,7 @@ export const createMember = async (userData: any): Promise<boolean> => {
export const deleteUser = async (id: string): Promise<boolean> => { export const deleteUser = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/users/${id}`, { const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -371,7 +536,7 @@ export const deleteUser = async (id: string): Promise<boolean> => {
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => { export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
try { try {
const response = await fetch(`${API_URL}/attendances/${id}`, { const response = await apiFetch(`${API_URL}/attendances/${id}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) return undefined; if (!response.ok) return undefined;
@@ -389,7 +554,7 @@ export const getAttendanceById = async (id: string): Promise<Attendance | undefi
export const getTenants = async (): Promise<any[]> => { export const getTenants = async (): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/tenants`, { const response = await apiFetch(`${API_URL}/tenants`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar tenants'); if (!response.ok) throw new Error('Falha ao buscar tenants');
@@ -407,7 +572,7 @@ export const getTenants = async (): Promise<any[]> => {
export const getTeams = async (tenantId: string): Promise<any[]> => { export const getTeams = async (tenantId: string): Promise<any[]> => {
try { try {
const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`, { const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!response.ok) throw new Error('Falha ao buscar equipes'); if (!response.ok) throw new Error('Falha ao buscar equipes');
@@ -420,7 +585,7 @@ export const getTeams = async (tenantId: string): Promise<any[]> => {
export const createTeam = async (teamData: any): Promise<boolean> => { export const createTeam = async (teamData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams`, { const response = await apiFetch(`${API_URL}/teams`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(teamData) body: JSON.stringify(teamData)
@@ -434,7 +599,7 @@ export const createTeam = async (teamData: any): Promise<boolean> => {
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => { export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams/${id}`, { const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(teamData) body: JSON.stringify(teamData)
@@ -448,7 +613,7 @@ export const updateTeam = async (id: string, teamData: any): Promise<boolean> =>
export const deleteTeam = async (id: string): Promise<boolean> => { export const deleteTeam = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/teams/${id}`, { const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -461,7 +626,7 @@ export const deleteTeam = async (id: string): Promise<boolean> => {
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => { export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
try { try {
const response = await fetch(`${API_URL}/tenants`, { const response = await apiFetch(`${API_URL}/tenants`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(tenantData) body: JSON.stringify(tenantData)
@@ -477,7 +642,7 @@ export const createTenant = async (tenantData: any): Promise<{ success: boolean;
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => { export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/tenants/${id}`, { const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(tenantData) body: JSON.stringify(tenantData)
@@ -491,7 +656,7 @@ export const updateTenant = async (id: string, tenantData: any): Promise<boolean
export const deleteTenant = async (id: string): Promise<boolean> => { export const deleteTenant = async (id: string): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/tenants/${id}`, { const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders() headers: getHeaders()
}); });
@@ -509,9 +674,23 @@ export let isReloadingForImpersonation = false;
export const logout = () => { export const logout = () => {
if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens
const refreshToken = localStorage.getItem('ctms_refresh_token');
// Clear local storage synchronously for instant UI update
localStorage.removeItem('ctms_token'); localStorage.removeItem('ctms_token');
localStorage.removeItem('ctms_refresh_token');
localStorage.removeItem('ctms_user_id'); localStorage.removeItem('ctms_user_id');
localStorage.removeItem('ctms_tenant_id'); localStorage.removeItem('ctms_tenant_id');
// Attempt to revoke in background
if (refreshToken) {
fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
}).catch(e => console.error("Failed to revoke refresh token", e));
}
}; };
export const login = async (credentials: any): Promise<any> => { export const login = async (credentials: any): Promise<any> => {
@@ -532,6 +711,7 @@ export const login = async (credentials: any): Promise<any> => {
const data = isJson ? await response.json() : null; const data = isJson ? await response.json() : null;
if (data && data.token) { if (data && data.token) {
localStorage.setItem('ctms_token', data.token); localStorage.setItem('ctms_token', data.token);
if (data.refreshToken) localStorage.setItem('ctms_refresh_token', data.refreshToken);
localStorage.setItem('ctms_user_id', data.user.id); localStorage.setItem('ctms_user_id', data.user.id);
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || ''); localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
} }
@@ -539,7 +719,7 @@ export const login = async (credentials: any): Promise<any> => {
}; };
export const impersonateTenant = async (tenantId: string): Promise<any> => { export const impersonateTenant = async (tenantId: string): Promise<any> => {
const response = await fetch(`${API_URL}/impersonate/${tenantId}`, { const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
method: 'POST', method: 'POST',
headers: getHeaders() headers: getHeaders()
}); });
@@ -607,7 +787,7 @@ export const returnToSuperAdmin = (): boolean => {
}; };
export const register = async (userData: any): Promise<boolean> => { export const register = async (userData: any): Promise<boolean> => {
const response = await fetch(`${API_URL}/auth/register`, { const response = await apiFetch(`${API_URL}/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData) body: JSON.stringify(userData)
@@ -620,7 +800,7 @@ export const register = async (userData: any): Promise<boolean> => {
}; };
export const verifyCode = async (data: any): Promise<boolean> => { export const verifyCode = async (data: any): Promise<boolean> => {
const response = await fetch(`${API_URL}/auth/verify`, { const response = await apiFetch(`${API_URL}/auth/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -633,7 +813,7 @@ export const verifyCode = async (data: any): Promise<boolean> => {
}; };
export const forgotPassword = async (email: string): Promise<string> => { export const forgotPassword = async (email: string): Promise<string> => {
const response = await fetch(`${API_URL}/auth/forgot-password`, { const response = await apiFetch(`${API_URL}/auth/forgot-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
@@ -652,7 +832,7 @@ export const forgotPassword = async (email: string): Promise<string> => {
}; };
export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => { export const resetPassword = async (password: string, token: string, name?: string): Promise<string> => {
const response = await fetch(`${API_URL}/auth/reset-password`, { const response = await apiFetch(`${API_URL}/auth/reset-password`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, token, name }) body: JSON.stringify({ password, token, name })

View File

@@ -23,6 +23,21 @@ export interface FunnelDef {
teamIds: string[]; teamIds: string[];
} }
export interface OriginItemDef {
id: string;
origin_group_id: string;
name: string;
color_class: string;
}
export interface OriginGroupDef {
id: string;
tenant_id: string;
name: string;
items: OriginItemDef[];
teamIds: string[];
}
export interface User { export interface User {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -42,14 +57,15 @@ export interface Attendance {
tenant_id: string; tenant_id: string;
user_id: string; user_id: string;
created_at: string; // ISO Date created_at: string; // ISO Date
summary: string; title: string;
full_summary?: string;
attention_points: string[]; attention_points: string[];
improvement_points: string[]; improvement_points: string[];
score: number; // 0-100 score: number; // 0-100
first_response_time_min: number; first_response_time_min: number;
handling_time_min: number; handling_time_min: number;
funnel_stage: FunnelStage; funnel_stage: string;
origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação'; origin: string;
product_requested: string; product_requested: string;
product_sold?: string; product_sold?: string;
converted: boolean; converted: boolean;