Compare commits

...

36 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
Cauê Faleiros
ef6d1582b3 feat: implement n8n api integration endpoints and api key management
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s
- Added api_keys table to database schema.

- Added API Key authentication middleware to express router.

- Created GET /api/integration/users endpoint for n8n to map agents.

- Created POST /api/integration/attendances endpoint to accept webhooks from n8n.

- Added UI in UserProfile (for Admins/Owners) to generate, view, and revoke API keys.
2026-03-16 14:29:21 -03:00
Cauê Faleiros
2ae0e9fdac docs: add n8n api integration to roadmap priority
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m54s
2026-03-16 14:04:05 -03:00
Cauê Faleiros
76c974bcd0 fix: integrate final user-provided notification sound and clean up unused assets
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m4s
- Replaced placeholder audio with the requested high-quality sound file.

- Removed deprecated audio files from the public directory.
2026-03-16 11:03:35 -03:00
Cauê Faleiros
b2f75562e7 fix: resolve notification routing bug, audio playback format, and backend 500 errors
- Fixed audio playback by downloading a valid mp3 file and importing it directly via Vite.

- Fixed the route collision where DELETE /notifications/clear-all was being captured by /notifications/:id.

- The notification badge now automatically clears (optimistic UI update) when the tray is opened.

- The backend no longer throws a 500 error when querying users during impersonation handoffs.
2026-03-13 16:33:44 -03:00
Cauê Faleiros
750ad525c8 fix: resolve notification ui bugs, audio playback, and team deletion
- Fixed audio playback by rendering a hidden audio tag to comply with browser policies.

- Renamed DELETE /notifications to /notifications/clear-all to prevent route conflicts.

- Notifications badge now clears automatically when the tray is opened.

- Translated notification types to Portuguese (SUCESSO, AVISO, ERRO, INFO).

- Implemented team deletion functionality for Admins.
2026-03-13 15:52:27 -03:00
Cauê Faleiros
4b0d84f2a0 style: rename 'Meus Funis' to 'Gerenciar Funis'
- Updated sidebar navigation and page title to accurately reflect the management nature of the funnels page.
2026-03-13 15:00:14 -03:00
Cauê Faleiros
ea8441d4be feat: implement advanced funnel management with multiple funnels and team assignments
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m32s
- Updated DB schema to support multiple funnels (funnels table) and their stages (funnel_stages table).

- Added funnel_id to teams table to link teams to specific funnels.

- Redesigned /admin/funnels page ('Meus Funis') to allow creating multiple funnels, managing their stages, and assigning them to teams.

- Updated Dashboard, UserDetail, and AttendanceDetail to dynamically load the correct funnel based on the selected team or user's assigned team.
2026-03-13 14:19:52 -03:00
Cauê Faleiros
7ab54053db feat: implement customizable funnel stages per tenant
- Modified attendances.funnel_stage in DB from ENUM to VARCHAR.

- Created tenant_funnels table and backend API routes to manage custom stages.

- Added /admin/funnels page for Admins/Managers to create, edit, order, and color-code their funnel stages.

- Updated Dashboard, UserDetail, and AttendanceDetail to fetch and render dynamic funnel stages instead of hardcoded enums.

- Added defensive checks and logging to GET /users/:idOrSlug to fix sporadic 500 errors during impersonation handoffs.
2026-03-13 10:25:23 -03:00
29 changed files with 2846 additions and 463 deletions

173
App.tsx
View File

@@ -1,31 +1,48 @@
import React, { useState, useEffect } from 'react';
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Layout } from './components/Layout';
import { Dashboard } from './pages/Dashboard';
import { UserDetail } from './pages/UserDetail';
import { AttendanceDetail } from './pages/AttendanceDetail';
import { SuperAdmin } from './pages/SuperAdmin';
import { TeamManagement } from './pages/TeamManagement';
import { Teams } from './pages/Teams';
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';
import React, { useState, useEffect } from "react";
import {
HashRouter as Router,
Routes,
Route,
Navigate,
useLocation,
} from "react-router-dom";
import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard";
import { UserDetail } from "./pages/UserDetail";
import { AttendanceDetail } from "./pages/AttendanceDetail";
import { SuperAdmin } from "./pages/SuperAdmin";
import { ApiKeys } from "./pages/ApiKeys";
import { TeamManagement } from "./pages/TeamManagement";
import { Teams } from "./pages/Teams";
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 [loading, setLoading] = useState(true);
const location = useLocation();
useEffect(() => {
const checkAuth = async () => {
const storedUserId = localStorage.getItem('ctms_user_id');
const storedToken = localStorage.getItem('ctms_token');
const storedUserId = localStorage.getItem("ctms_user_id");
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
setLoading(false);
return;
@@ -33,15 +50,27 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
try {
const fetchedUser = await getUserById(storedUserId);
if (fetchedUser && fetchedUser.status === 'active') {
if (fetchedUser) {
if (fetchedUser.status === "active") {
setUser(fetchedUser);
} else {
// User explicitly marked inactive or deleted
logout();
setUser(null);
}
} 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();
setUser(null);
}
} catch (err) {
console.error("Auth check failed", err);
logout();
console.error("Auth check failed (network/server error):", err);
// 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);
} finally {
setLoading(false);
@@ -51,7 +80,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
}, [location.pathname]);
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) {
@@ -62,6 +95,11 @@ const AuthGuard: React.FC<{ children: React.ReactNode, roles?: string[] }> = ({
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>;
};
@@ -73,13 +111,86 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/setup-account" element={<SetupAccount />} />
<Route path="/" element={<AuthGuard><Dashboard /></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="/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="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} />
<Route
path="/"
element={
<AuthGuard>
<Dashboard />
</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 />} />
</Routes>
</Router>

View File

@@ -7,22 +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**:
- **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.
- **Role-Based Access Control (RBAC):**
- **Super Admin:** Global management of all tenants and users (via the hidden `system` tenant).
- **Admin/Manager:** Full control over members and teams within their specific organization.
- **Advanced 2-Token Authentication (Rolling Sessions):**
- Replaced the vulnerable 1-year static JWT with a highly secure dual-token system.
- Generates a short-lived `AccessToken` (15 min) and a stateful `RefreshToken` (30 days) stored in the DB (`refresh_tokens` table).
- 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.
- Full remote revocation capability (Logout drops the token from the DB immediately).
- **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.
- **Role-Based Access Control (RBAC) Simplification:**
- Removed the redundant 'owner' role. The system now strictly relies on 4 tiers:
- **Super Admin:** Global management of all tenants and API keys (via the hidden `system` tenant).
- **Admin:** Full control over members, teams, funnels, and origins 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.
- **Premium "Onyx & Gold" UI/UX:** Completely redesigned the dark mode using a true neutral Charcoal (Zinc) palette, high-contrast text, and brand Yellow accents.
- **Dynamic KPI Dashboard:** Implemented true period-over-period trend calculations for Leads, Quality Scores, and Response Times.
- **Secure File Uploads:** Profile avatars are now securely uploaded using `multer` with strict mimetype validation (JPG/PNG/WEBP), 2MB size limits, and UUID generation to prevent path traversal.
- **Enhanced Security Flows:**
- User routing uses secure `slugs` instead of exposing raw UUIDs.
- All password reset and setup tokens strictly expire in 15 minutes and are destroyed upon use.
- Separated the "Reset Password" and "Setup Account" (for new admins) flows for better UX.
- **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:**
- Premium "Onyx & Gold" True Black dark mode (Zinc scale).
- Fully collapsible interactive sidebar with memory (`localStorage`).
- All Date/Time displays localized to strict Brazilian formatting (`pt-BR`, 24h, `DD/MM/YY`).
## 📌 Roadmap / To-Do
- [ ] **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`).
- [ ] **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.
## 🛠 Architecture
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN).
- **Backend**: Node.js, Express, MySQL2 (Pool-based).
- **Database**: MySQL 8.0 (Schema: `fasto_db`).
- **Frontend**: React 19, TypeScript, Vite, TailwindCSS (CDN), Recharts, Lucide React.
- **Backend**: Node.js, Express, MySQL2 (Pool-based), Nodemailer.
- **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.
## 📋 Prerequisites
@@ -36,11 +58,10 @@ Copy `.env.example` to `.env` and adjust values:
```bash
cp .env.example .env
```
Ensure you set the database credentials (`DB_NAME=fasto_db` for production) and `GITEA_RUNNER_REGISTRATION_TOKEN`.
*Note:* The backend automatically strips literal quotes from Docker `.env` string values (like `SMTP_PASS`) to prevent authentication crashes.
### 2. Database
The project expects a MySQL database. The `docker-compose.local.yml` initializes it with `agenciac_comia.sql`.
*Note for Production:* If migrating from an old version, you must manually run the SQL to create the `password_resets` and `pending_registrations` tables, or rebuild the volume.
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)
To start the application and database locally:

View File

@@ -101,23 +101,52 @@ app.use('/uploads', express.static(uploadDir, {
const apiRouter = express.Router();
// Middleware de autenticação
const authenticateToken = (req, res, next) => {
const authenticateToken = async (req, res, next) => {
// Ignorar rotas de auth
if (req.path.startsWith('/auth/')) return next();
const authHeader = req.headers['authorization'];
// API Key Authentication for n8n/External Integrations
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
const apiKey = authHeader.split(' ')[1];
try {
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
// Update last used timestamp
await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]);
// Attach a "system/bot" user identity to the request based on the tenant
req.user = {
id: 'bot_integration',
tenant_id: keys[0].tenant_id,
role: 'admin', // Give integration admin privileges within its tenant
is_api_key: true
};
return next();
} catch (error) {
console.error('API Key validation error:', error);
return res.status(500).json({ error: 'Erro ao validar chave de API.' });
}
}
// Standard JWT Authentication
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' });
try {
const user = jwt.verify(token, JWT_SECRET);
req.user = user;
next();
});
} catch (err) {
return res.status(401).json({ error: 'Token inválido ou expirado.' });
}
};
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.' });
next();
};
@@ -180,8 +209,7 @@ apiRouter.post('/auth/verify', async (req, res) => {
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
[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 (?, ?, ?, ?, ?, ?)',
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']);
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
[userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
await connection.commit();
res.json({ message: 'Sucesso.' });
@@ -210,8 +238,78 @@ apiRouter.post('/auth/login', async (req, res) => {
const valid = await bcrypt.compare(password, user.password_hash);
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' });
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 } });
// Generate Access Token (short-lived)
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) {
res.status(500).json({ error: error.message });
}
@@ -305,6 +403,17 @@ apiRouter.post('/auth/reset-password', async (req, res) => {
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
);
}
// If the new user is an admin, notify super_admins too
if (user.role === 'admin') {
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
for (const sa of superAdmins) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
);
}
}
}
} else {
// Standard password reset, just update the hash
@@ -350,12 +459,17 @@ apiRouter.get('/users', async (req, res) => {
apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (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 || !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) {
return res.status(403).json({ error: 'Acesso negado.' });
}
res.json(rows[0]);
} catch (error) { res.status(500).json({ error: error.message }); }
} catch (error) {
console.error('Error in GET /users/:idOrSlug:', error);
res.status(500).json({ error: error.message });
}
});
// Convidar Novo Membro (Admin criando usuário)
@@ -429,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
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) {
return res.status(403).json({ error: 'Acesso negado.' });
@@ -439,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
return res.status(403).json({ error: 'Acesso negado.' });
}
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
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 finalEmail = email !== undefined ? email : existing[0].email;
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
@@ -454,13 +570,25 @@ apiRouter.put('/users/:id', async (req, res) => {
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
);
// Trigger Notification for Team Change
if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
if (team.length > 0) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/']
);
}
}
res.json({ message: 'User updated successfully.' });
} catch (error) { console.error('Update user error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
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' });
@@ -510,6 +638,18 @@ apiRouter.get('/notifications', async (req, res) => {
}
});
apiRouter.put('/notifications/read-all', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications marked as read' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/notifications/:id', async (req, res) => {
try {
await pool.query(
@@ -522,13 +662,13 @@ apiRouter.put('/notifications/:id', async (req, res) => {
}
});
apiRouter.put('/notifications/read-all', async (req, res) => {
apiRouter.delete('/notifications/clear-all', async (req, res) => {
try {
await pool.query(
'UPDATE notifications SET is_read = true WHERE user_id = ?',
'DELETE FROM notifications WHERE user_id = ?',
[req.user.id]
);
res.json({ message: 'All notifications marked as read' });
res.json({ message: 'All notifications deleted' });
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -546,13 +686,261 @@ apiRouter.delete('/notifications/:id', async (req, res) => {
}
});
apiRouter.delete('/notifications', 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(
'DELETE FROM notifications WHERE user_id = ?',
[req.user.id]
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, gid, origin.name, origin.color]
);
res.json({ message: 'All notifications deleted' });
}
// 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 ---
apiRouter.get('/funnels', 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 [funnels] = await pool.query('SELECT * FROM funnels WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
// Seed default funnel if none exists
if (funnels.length === 0) {
const fid = `funnel_${crypto.randomUUID().split('-')[0]}`;
await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, 'Funil Padrão']);
const defaultStages = [
{ name: 'Sem atendimento', color: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border', order: 0 },
{ name: 'Identificação', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800', order: 1 },
{ name: 'Negociação', color: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800', order: 2 },
{ name: 'Ganhos', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800', order: 3 },
{ name: 'Perdidos', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800', order: 4 }
];
for (const s of defaultStages) {
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
[sid, fid, s.name, s.color, s.order]
);
}
// Update all teams of this tenant to use this funnel if they have none
await pool.query('UPDATE teams SET funnel_id = ? WHERE tenant_id = ? AND funnel_id IS NULL', [fid, effectiveTenantId]);
funnels.push({ id: fid, tenant_id: effectiveTenantId, name: 'Funil Padrão' });
}
const [stages] = await pool.query('SELECT * FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
const [teams] = await pool.query('SELECT id, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [effectiveTenantId]);
const result = funnels.map(f => ({
...f,
stages: stages.filter(s => s.funnel_id === f.id),
teamIds: teams.filter(t => t.funnel_id === f.id).map(t => t.id)
}));
res.json(result);
} catch (error) {
console.error("GET /funnels error:", error);
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/funnels', 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 fid = `funnel_${crypto.randomUUID().split('-')[0]}`;
await pool.query('INSERT INTO funnels (id, tenant_id, name) VALUES (?, ?, ?)', [fid, effectiveTenantId, name]);
res.status(201).json({ id: fid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, teamIds } = req.body;
try {
if (name) {
await pool.query('UPDATE funnels SET name = ? WHERE id = ?', [name, req.params.id]);
}
if (teamIds && Array.isArray(teamIds)) {
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
if (teamIds.length > 0) {
await pool.query('UPDATE teams SET funnel_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
}
}
res.json({ message: 'Funnel updated.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
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('DELETE FROM funnels WHERE id = ?', [req.params.id]);
res.json({ message: 'Funnel deleted.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body;
try {
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO funnel_stages (id, funnel_id, name, color_class, order_index) VALUES (?, ?, ?, ?, ?)',
[sid, req.params.id, name, color_class, order_index || 0]
);
res.status(201).json({ id: sid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
const { name, color_class, order_index } = req.body;
try {
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Stage not found' });
await pool.query(
'UPDATE funnel_stages SET name = ?, color_class = ?, order_index = ? WHERE id = ?',
[name || existing[0].name, color_class || existing[0].color_class, order_index !== undefined ? order_index : existing[0].order_index, req.params.id]
);
res.json({ message: 'Stage updated.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
res.json({ message: 'Stage deleted.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -574,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') {
// No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
} else if (req.user.role === 'admin') {
membersQ += ' AND tenant_id = ?';
membersParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') {
@@ -592,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
if (req.user.role === 'super_admin') {
// No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
} else if (req.user.role === 'admin') {
teamsQ += ' AND tenant_id = ?';
teamsParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') {
@@ -610,12 +998,12 @@ apiRouter.get('/search', async (req, res) => {
}
// 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];
if (req.user.role === 'super_admin') {
// No extra filters
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
} else if (req.user.role === 'admin') {
attendancesQ += ' AND a.tenant_id = ?';
attendancesParams.push(req.user.tenant_id);
} else if (req.user.role === 'manager') {
@@ -705,6 +1093,189 @@ apiRouter.get('/attendances/:id', async (req, res) => {
} catch (error) { res.status(500).json({ error: error.message }); }
});
// --- API Key Management Routes ---
apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), 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 [rows] = await pool.query('SELECT id, name, created_at, last_used_at, CONCAT(SUBSTRING(secret_key, 1, 14), "...") as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId]);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/api-keys', 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 id = `apk_${crypto.randomUUID().split('-')[0]}`;
// Generate a strong, random 32-byte hex string for the secret key
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
await pool.query(
'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)',
[id, effectiveTenantId, name || 'Nova Integração API', secretKey]
);
// We only return the actual secret key ONCE during creation.
res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
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 (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' });
await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]);
res.json({ message: 'Chave de API revogada com sucesso.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// --- External Integration API (n8n) ---
apiRouter.get('/integration/users', 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 [rows] = await pool.query(
'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"',
[req.user.tenant_id]
);
res.json(rows);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
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) => {
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
const {
user_id,
origin,
funnel_stage,
title,
full_summary,
score,
first_response_time_min,
handling_time_min,
product_requested,
product_sold,
converted,
attention_points,
improvement_points
} = req.body;
if (!user_id || !origin || !funnel_stage || !title) {
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
}
try {
// Validate user belongs to the API Key's tenant
const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]);
if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' });
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
`INSERT INTO attendances (
id, tenant_id, user_id, title, full_summary, score,
first_response_time_min, handling_time_min,
funnel_stage, origin, product_requested, product_sold,
converted, attention_points, improvement_points
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
attId,
req.user.tenant_id,
user_id,
title,
full_summary || null,
score || 0,
first_response_time_min || 0,
handling_time_min || 0,
funnel_stage,
origin,
product_requested || null,
product_sold || null,
converted ? 1 : 0,
attention_points ? JSON.stringify(attention_points) : null,
improvement_points ? JSON.stringify(improvement_points) : null
]
);
// Automation Trigger: "Venda Fechada!" (Ganhos)
if (converted) {
// Find the user's manager/admin
const [managers] = await pool.query(
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?",
[req.user.tenant_id, user_id]
);
const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]);
const agentName = agentInfo[0]?.name || 'Um agente';
for (const m of managers) {
await pool.query(
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
[crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/attendances/${attId}`]
);
}
}
res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' });
} catch (error) {
console.error('Integration Error:', error);
res.status(500).json({ error: error.message });
}
});
// --- Tenant Routes ---
apiRouter.get('/tenants', requireRole(['super_admin']), async (req, res) => {
try {
@@ -745,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 effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
try {
@@ -761,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;
try {
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
@@ -781,6 +1352,25 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
}
});
apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
try {
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 (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
}
// Set users team_id to NULL to prevent orphan foreign key issues if constrained
await pool.query('UPDATE users SET team_id = NULL WHERE team_id = ?', [req.params.id]);
await pool.query('DELETE FROM teams WHERE id = ?', [req.params.id]);
res.json({ message: 'Team deleted successfully.' });
} catch (error) {
console.error('Delete team error:', error);
res.status(500).json({ error: error.message });
}
});
apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => {
@@ -942,15 +1532,143 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
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 {
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) {
console.log('Schema update note (origin):', err.message);
}
connection.release();
// Convert funnel_stage to VARCHAR for custom funnels
try {
await connection.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL");
} catch (err) {
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
await connection.query(`
CREATE TABLE IF NOT EXISTS funnels (
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 funnel_stages table
await connection.query(`
CREATE TABLE IF NOT EXISTS funnel_stages (
id varchar(36) NOT NULL,
funnel_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
order_index int DEFAULT 0,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY funnel_id (funnel_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// Create api_keys table for external integrations (n8n)
await connection.query(`
CREATE TABLE IF NOT EXISTS api_keys (
id varchar(36) NOT NULL,
tenant_id varchar(36) NOT NULL,
name varchar(255) NOT NULL,
secret_key varchar(255) NOT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY secret_key (secret_key),
KEY tenant_id (tenant_id)
) 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
try {
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.funnel_id):', err.message);
}
connection.release();
// Ensure system tenant exists
await pool.query('INSERT IGNORE INTO tenants (id, name, slug, admin_email, status) VALUES (?, ?, ?, ?, ?)', ['system', 'System Admin', 'system', email, 'active']);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { Calendar } from 'lucide-react';
import { DateRange } from '../types';
@@ -8,42 +8,96 @@ interface DateRangePickerProps {
}
export const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => {
const startRef = useRef<HTMLInputElement>(null);
const endRef = useRef<HTMLInputElement>(null);
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 newStart = new Date(e.target.value);
if (!isNaN(newStart.getTime())) {
const newStart = parseLocalDate(e.target.value);
if (newStart && !isNaN(newStart.getTime())) {
onChange({ ...dateRange, start: newStart });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = new Date(e.target.value);
if (!isNaN(newEnd.getTime())) {
const newEnd = parseLocalDate(e.target.value);
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 });
}
};
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 (
<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" />
<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">
{/* Start Date */}
<div
className="relative cursor-pointer hover:text-brand-yellow transition-colors"
onClick={() => openPicker(startRef)}
>
{formatShortDate(dateRange.start)}
<input
ref={startRef}
type="date"
value={formatDateForInput(dateRange.start)}
onChange={handleStartChange}
className="bg-transparent text-zinc-700 dark:text-zinc-200 font-medium outline-none cursor-pointer w-28 md:w-auto"
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
<span className="text-zinc-400 dark:text-dark-muted">até</span>
</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="bg-transparent text-zinc-700 dark:text-zinc-200 font-medium outline-none cursor-pointer w-28 md:w-auto"
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
</div>
</div>
);
};

View File

@@ -2,7 +2,8 @@ import React, { useState, useEffect } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
Hexagon, Settings, Building2, Sun, Moon, Loader2
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
ChevronLeft, ChevronRight, Key, Target
} from 'lucide-react';
import {
getAttendances, getUsers, getUserById, logout, searchGlobal,
@@ -10,10 +11,12 @@ import {
deleteNotification, clearAllNotifications, returnToSuperAdmin
} from '../services/dataService';
import { User } from '../types';
import notificationSound from '../src/assets/audio/notification.mp3';
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
<NavLink
to={to}
end
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
isActive
@@ -29,11 +32,20 @@ const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: a
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('ctms_sidebar_collapsed') === 'true';
});
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const location = useLocation();
const navigate = useNavigate();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const toggleSidebar = () => {
const newState = !isSidebarCollapsed;
setIsSidebarCollapsed(newState);
localStorage.setItem('ctms_sidebar_collapsed', String(newState));
};
// Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }>({ members: [], teams: [], attendances: [], organizations: [] });
@@ -49,12 +61,6 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
// Pre-initialize audio to ensure it's loaded and ready
const audioRef = React.useRef<HTMLAudioElement | null>(null);
useEffect(() => {
// Determine base path correctly whether in prod or dev
const basePath = import.meta.env.PROD ? '' : 'http://localhost:3001';
audioRef.current = new Audio(`${basePath}/audio/notification.mp3`);
audioRef.current.volume = 0.5;
}, []);
const playNotificationSound = () => {
if (currentUser?.sound_enabled !== false && audioRef.current) {
@@ -69,7 +75,6 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const loadNotifications = async () => {
const data = await getNotifications();
setNotifications(data);
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
@@ -78,10 +83,24 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
playNotificationSound();
}
setNotifications(data);
previousUnreadCountRef.current = newUnreadCount;
isInitialLoadRef.current = false;
};
const handleBellClick = async () => {
const willOpen = !showNotifications;
setShowNotifications(willOpen);
if (willOpen && unreadCount > 0) {
// Optimistic update
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
previousUnreadCountRef.current = 0;
await markAllNotificationsAsRead();
loadNotifications();
}
};
useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
if (searchQuery.length >= 2) {
@@ -167,33 +186,65 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<div className="flex h-screen bg-zinc-50 dark:bg-dark-bg overflow-hidden transition-colors duration-300">
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-dark-sidebar border-r border-zinc-200 dark:border-dark-border transform transition-transform duration-300 ease-in-out lg:relative lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-50 bg-white dark:bg-dark-sidebar border-r border-zinc-200 dark:border-dark-border transform transition-all duration-300 ease-in-out lg:relative lg:translate-x-0 ${
isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'
}`}
} flex flex-col ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center gap-3 px-6 h-20 border-b border-zinc-100 dark:border-dark-border">
<div className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 p-2 rounded-lg transition-colors">
{/* Logo & Toggle */}
<div className="flex items-center justify-between px-4 h-20 border-b border-zinc-100 dark:border-dark-border shrink-0">
<div className={`flex items-center gap-3 overflow-hidden ${isSidebarCollapsed ? 'mx-auto' : ''}`}>
<div className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 p-2 rounded-lg transition-colors shrink-0">
<Hexagon size={24} fill="currentColor" />
</div>
<span className="text-xl font-bold text-zinc-900 dark:text-white tracking-tight">Fasto<span className="text-brand-yellow">.</span></span>
<button onClick={() => setIsMobileMenuOpen(false)} className="ml-auto lg:hidden text-zinc-400">
{!isSidebarCollapsed && (
<span className="text-xl font-bold text-zinc-900 dark:text-white tracking-tight whitespace-nowrap">Fasto<span className="text-brand-yellow">.</span></span>
)}
</div>
{!isSidebarCollapsed && (
<button
onClick={toggleSidebar}
className="hidden lg:flex p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg transition-colors shrink-0"
title="Recolher Menu"
>
<ChevronLeft size={18} />
</button>
)}
<button onClick={() => setIsMobileMenuOpen(false)} className="lg:hidden text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
<X size={24} />
</button>
</div>
{/* Expand Button when collapsed */}
{isSidebarCollapsed && (
<div className="hidden lg:flex justify-center p-2 border-b border-zinc-100 dark:border-dark-border shrink-0">
<button
onClick={toggleSidebar}
className="p-1.5 text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg transition-colors"
title="Expandir Menu"
>
<ChevronRight size={18} />
</button>
</div>
)}
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
<nav className={`flex-1 py-6 space-y-2 overflow-y-auto overflow-x-hidden ${isSidebarCollapsed ? 'px-2' : 'px-4'}`}>
{/* Standard User Links */}
{!isSuperAdmin && (
<>
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
{currentUser.role !== 'agent' && (
<>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={false} />
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={false} />
<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} />
{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} />
</>
)}
</>
)}
</>
@@ -202,39 +253,44 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{/* Super Admin Links */}
{isSuperAdmin && (
<>
<div className="pt-2 pb-2 px-4 text-xs font-semibold text-zinc-400 dark:text-dark-muted uppercase tracking-wider">
{!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">
Super Admin
</div>
<SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={false} />
<SidebarItem to="/admin/users" icon={Users} label="Usuários Globais" collapsed={false} />
</>
)}
</nav>
<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="/super-admin/api-keys" icon={Key} label="Integrações" collapsed={isSidebarCollapsed} />
</>
)} </nav>
{/* User Profile Mini - Now Clickable to Profile */}
<div className="p-4 border-t border-zinc-100 dark:border-dark-border space-y-3">
<div className="p-3 border-t border-zinc-100 dark:border-dark-border space-y-3 shrink-0">
{localStorage.getItem('ctms_super_admin_token') && (
<button
onClick={() => {
returnToSuperAdmin();
}}
className="w-full flex items-center justify-center gap-2 py-2 px-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-xs font-bold hover:opacity-90 transition-colors"
className={`w-full flex items-center justify-center gap-2 py-2 px-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-xs font-bold hover:opacity-90 transition-colors ${isSidebarCollapsed ? 'px-0' : ''}`}
title="Retornar ao Painel Central"
>
Retornar ao Painel Central
{isSidebarCollapsed ? <LogOut size={16} /> : 'Retornar ao Painel Central'}
</button>
)}
<div className="flex items-center gap-3 p-2 rounded-lg bg-zinc-50 dark:bg-dark-bg/50 border border-zinc-100 dark:border-dark-border group">
<div className={`flex items-center gap-3 rounded-xl bg-zinc-50 dark:bg-dark-bg/50 border border-zinc-100 dark:border-dark-border group transition-all ${isSidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}>
<div
onClick={() => navigate('/profile')}
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
className={`flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity ${isSidebarCollapsed ? 'justify-center' : ''}`}
title={isSidebarCollapsed ? currentUser.name : undefined}
>
<img
src={currentUser.avatar_url
? (currentUser.avatar_url.startsWith('http') ? currentUser.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${currentUser.avatar_url}`)
: `https://ui-avatars.com/api/?name=${encodeURIComponent(currentUser.name)}&background=random`}
alt={currentUser.name}
className="w-10 h-10 rounded-full object-cover border border-zinc-200 dark:border-dark-border"
className="w-10 h-10 rounded-full object-cover border border-zinc-200 dark:border-dark-border shrink-0"
/>
{!isSidebarCollapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{currentUser.name}</p>
<p className="text-xs text-zinc-500 dark:text-dark-muted truncate capitalize">
@@ -243,15 +299,17 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
currentUser.role === 'manager' ? 'Gerente' : 'Agente'}
</p>
</div>
)}
</div>
{!isSidebarCollapsed && (
<button
onClick={handleLogout}
className="text-zinc-400 hover:text-red-500 transition-colors shrink-0"
className="text-zinc-400 hover:text-red-500 transition-colors shrink-0 p-2"
title="Sair"
>
<LogOut size={18} />
</button>
</div>
)}
</div>
</div>
</aside>
@@ -390,10 +448,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
KPI
</div>
<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">
<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>
</button>
@@ -424,10 +482,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{/* Notifications */}
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
onClick={handleBellClick}
className="p-2 text-zinc-500 dark:text-dark-muted hover:bg-zinc-100 dark:hover:bg-dark-border rounded-full relative transition-colors"
>
<Bell size={20} />
> <Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-2 w-2.5 h-2.5 bg-brand-yellow rounded-full border-2 border-white dark:border-dark-header"></span>
)}
@@ -486,10 +543,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
n.type === 'warning' ? 'text-orange-500' :
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
}`}>
{n.type}
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
</span>
<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>
</div>
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
@@ -538,6 +595,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Hidden Audio Player for Notifications */}
<audio
ref={audioRef}
src={notificationSound}
preload="auto"
/>
</div>
);
};

View File

@@ -1,164 +1,5 @@
import { Attendance, FunnelStage, Tenant, User } 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);
import { FunnelStage } from './types';
// Visual Constants
export const COLORS = {

27
debug.txt Normal file
View File

@@ -0,0 +1,27 @@
Look at `playNotificationSound`:
```javascript
const playNotificationSound = () => {
if (currentUser?.sound_enabled !== false && audioRef.current) {
// Reset time to 0 to allow rapid replays
audioRef.current.currentTime = 0;
const playPromise = audioRef.current.play();
```
Is `currentUser` loaded when `loadNotifications` fires for the first time after `isInitialLoadRef` is false?
Yes, `useEffect` calls `fetchCurrentUser()`, which sets `currentUser`.
Wait. `setInterval` uses a closure over the state!
```javascript
useEffect(() => {
fetchCurrentUser();
loadNotifications();
const interval = setInterval(loadNotifications, 10000);
return () => clearInterval(interval);
}, [navigate]);
```
Oh my god. The `setInterval` callback `loadNotifications` captures the *initial* state variables, including `currentUser`, which is `null` on the first render!
If `currentUser` is `null` inside the closure, `currentUser?.sound_enabled !== false` evaluates to `true !== false` which is `true`. So that's not blocking it.
BUT `audioRef.current` might not have been rendered yet? No, `audioRef` is a ref, so it mutates in place. The closure always sees the latest `audioRef.current`.
So why does it fail or not play?
Is the browser policy blocking it silently without logging?
Let's add a robust, standalone Audio approach that doesn't rely on the DOM tag if it fails, or maybe just force a click handler to "unlock" the audio context.

36
debug2.txt Normal file
View File

@@ -0,0 +1,36 @@
Let's see what happens during returnToSuperAdmin:
1. Decode superAdminToken
2. localStorage.setItem('ctms_token', superAdminToken)
3. localStorage.setItem('ctms_user_id', payload.id) <-- u_71657ec7
4. localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system') <-- 'system'
5. window.location.reload()
Then the app reloads.
1. AuthGuard mounts
2. const storedUserId = localStorage.getItem('ctms_user_id') <-- u_71657ec7
3. const fetchedUser = await getUserById(storedUserId)
-> GET /api/users/u_71657ec7
Backend handles GET /api/users/u_71657ec7:
req.user is derived from jwt.verify(superAdminToken)
So req.user.role === 'super_admin'
req.user.tenant_id === 'system' (or null)
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { ... }
res.json(rows[0]);
Wait. If this is a 500 error, why does it crash?
What if `req.user` is undefined? But `authenticateToken` middleware ensures `req.user` exists.
Maybe `pool.query` fails?
No, wait. You said:
"now its sending me to login page, congrats"
Then I fixed the Base64 pad.
Then you said:
"i am still having this problem, when i go to impersionate the pages blink and i go to the tenant pages, good, but when i press to go back and stop impersionate it redirects me to the goddam login page"
Then you said:
"ok, its working but its giving some errors in dev tools and this errors is showing our users id and path ... XHRGET https://fasto.blyzer.com.br/api/users/u_71657ec7 [HTTP/2 500 14ms]"
Why 500?
Let's add a console.log in the catch block of `/users/:idOrSlug` to see the actual error.

20
debug3.txt Normal file
View File

@@ -0,0 +1,20 @@
Wait, let's look at `index.js` line 354 BEFORE my fix:
```javascript
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
```
The ONLY way this throws 500 is if:
1. `pool.query` fails (e.g. database disconnected, which isn't the case).
2. `req.user` is somehow null or undefined (but `authenticateToken` guarantees it exists).
3. `rows[0]` is undefined (but `rows.length === 0` handles that).
Wait, what if `req.user.tenant_id` is null? `null !== 'tenant_xyz'` is true, so it returns 403, not 500.
What if the 500 is coming from `GET /api/users/u_71657ec7` but it's not actually hitting `/users/:idOrSlug`?
Is there a middleware or something? No.
Ah! What if the user you are impersonating was deleted from the database? `rows.length === 0` -> returns 404, not 500.
Let's check the local logs AGAIN after my recent rebuild. I added `console.error('Error in GET /users/:idOrSlug:', error);`
Let's deliberately trigger the error locally to see it. But I don't have the browser.

View File

@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
app:
@@ -43,12 +43,31 @@ services:
networks:
- 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:
db_data:
configs:
init_sql:
file: ./agenciac_comia.sql
name: init_sql_v2
networks:
fasto-net:

10
fix_db.js Normal file
View File

@@ -0,0 +1,10 @@
const mysql = require('mysql2/promise');
async function run() {
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
try {
await pool.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL DEFAULT 'Novo'");
console.log("Success");
} catch(e) { console.error(e); }
pool.end();
}
run();

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

@@ -1,13 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { getAttendanceById, getUserById } from '../services/dataService';
import { Attendance, User, FunnelStage } from '../types';
import { getAttendanceById, getUserById, getFunnels } from '../services/dataService';
import { Attendance, User, FunnelStage, FunnelStageDef } from '../types';
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Calendar, MessageSquare, ShoppingBag, Award, TrendingUp } from 'lucide-react';
export const AttendanceDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [data, setData] = useState<Attendance | undefined>();
const [agent, setAgent] = useState<User | undefined>();
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -15,11 +16,26 @@ export const AttendanceDetail: React.FC = () => {
if (id) {
setLoading(true);
try {
const att = await getAttendanceById(id);
const tenantId = localStorage.getItem('ctms_tenant_id') || '';
const [att, fetchedFunnels] = await Promise.all([
getAttendanceById(id),
getFunnels(tenantId)
]);
setData(att);
if (att) {
const u = await getUserById(att.user_id);
setAgent(u);
// Determine which funnel was used based on the agent's team
const targetTeamId = u?.team_id || null;
let activeFunnel = fetchedFunnels[0];
if (targetTeamId) {
const matchedFunnel = fetchedFunnels.find(f => f.teamIds?.includes(targetTeamId));
if (matchedFunnel) activeFunnel = matchedFunnel;
}
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages : []);
}
} catch (error) {
console.error("Error loading details", error);
@@ -34,7 +50,10 @@ export const AttendanceDetail: React.FC = () => {
if (loading) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando detalhes...</div>;
if (!data) return <div className="p-12 text-center text-zinc-500 dark:text-dark-muted transition-colors">Registro de atendimento não encontrado</div>;
const getStageColor = (stage: FunnelStage) => {
const getStageColor = (stage: string) => {
const def = funnelDefs.find(f => f.name === stage);
if (def) return def.color_class;
switch (stage) {
case FunnelStage.WON: return 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-900/30 dark:border-green-800';
case FunnelStage.LOST: return 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-900/30 dark:border-red-800';
@@ -79,7 +98,7 @@ export const AttendanceDetail: React.FC = () => {
</span>
</div>
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
{data.summary}
{data.title}
</h1>
{agent && (
<div className="flex items-center gap-3 pt-2">
@@ -117,11 +136,17 @@ export const AttendanceDetail: React.FC = () => {
<MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" />
Resumo da Interação
</h3>
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm">
{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>.
<div className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap">
{data.full_summary ? (
data.full_summary
) : (
<>
{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'}.
</p>
</>
)}
</div>
</div>
{/* Feedback Section */}

View File

@@ -5,9 +5,9 @@ import {
import {
Users, Clock, Phone, TrendingUp, Filter
} from 'lucide-react';
import { getAttendances, getUsers, getTeams, getUserById } from '../services/dataService';
import { getAttendances, getUsers, getTeams, getUserById, getFunnels, getOrigins } from '../services/dataService';
import { COLORS } from '../constants';
import { Attendance, DashboardFilter, FunnelStage, User } from '../types';
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types';
import { KPICard } from '../components/KPICard';
import { DateRangePicker } from '../components/DateRangePicker';
import { SellersTable } from '../components/SellersTable';
@@ -28,6 +28,8 @@ export const Dashboard: React.FC = () => {
const [prevData, setPrevData] = useState<Attendance[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [originDefs, setOriginDefs] = useState<OriginItemDef[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [filters, setFilters] = useState<DashboardFilter>({
@@ -55,12 +57,14 @@ export const Dashboard: React.FC = () => {
const prevEnd = new Date(filters.dateRange.start.getTime());
const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } };
// Fetch users, attendances, teams and current user in parallel
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, me] = await Promise.all([
// Fetch users, attendances, teams, funnels and current user in parallel
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, fetchedOrigins, me] = await Promise.all([
getUsers(tenantId),
getAttendances(tenantId, filters),
getAttendances(tenantId, prevFilters),
getTeams(tenantId),
getFunnels(tenantId),
getOrigins(tenantId),
storedUserId ? getUserById(storedUserId) : null
]);
@@ -68,7 +72,27 @@ export const Dashboard: React.FC = () => {
setData(fetchedData);
setPrevData(prevFetchedData);
setTeams(fetchedTeams);
setOriginDefs(fetchedOrigins);
if (me) setCurrentUser(me);
// Determine which funnel to display
const targetTeamId = filters.teamId !== 'all' ? filters.teamId : (me?.team_id || null);
let activeFunnel = fetchedFunnels[0]; // fallback to first/default
if (targetTeamId) {
const matchedFunnel = fetchedFunnels.find(f => f.teamIds?.includes(targetTeamId));
if (matchedFunnel) activeFunnel = matchedFunnel;
}
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) {
console.error("Error loading dashboard data:", error);
} finally {
@@ -128,6 +152,20 @@ export const Dashboard: React.FC = () => {
// --- Chart Data: Funnel ---
const funnelData = useMemo(() => {
const counts = data.reduce((acc, curr) => {
acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1;
return acc;
}, {} as Record<string, number>);
if (funnelDefs.length > 0) {
return funnelDefs.map(stage => ({
name: stage.name,
value: counts[stage.name] || 0,
color: stage.color_class.split(' ')[0].replace('bg-', '') // Extract base color name for Recharts
}));
}
// Fallback if funnels aren't loaded yet
const stagesOrder = [
FunnelStage.NO_CONTACT,
FunnelStage.IDENTIFICATION,
@@ -135,30 +173,65 @@ export const Dashboard: React.FC = () => {
FunnelStage.WON,
FunnelStage.LOST
];
const counts = data.reduce((acc, curr) => {
acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return stagesOrder.map(stage => ({
name: stage,
value: counts[stage] || 0
}));
}, [data]);
}, [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 ---
const originData = useMemo(() => {
const origins = data.reduce((acc, curr) => {
const counts = data.reduce((acc, curr) => {
acc[curr.origin] = (acc[curr.origin] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Ensure type safety for value in sort
return (Object.entries(origins) as [string, number][])
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}, [data]);
if (originDefs.length > 0) {
const activeOrigins = originDefs.map(def => {
let hexColor = '#71717a'; // Default zinc
if (def.color_class) {
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 ---
const sellersRanking = useMemo(() => {
@@ -272,24 +345,24 @@ export const Dashboard: React.FC = () => {
onChange={(e) => handleFilterChange('funnelStage', e.target.value)}
>
<option value="all">Todas Etapas</option>
{Object.values(FunnelStage).map(stage => (
{funnelDefs.length > 0 ? funnelDefs.map(stage => (
<option key={stage.id} value={stage.name}>{stage.name}</option>
)) : Object.values(FunnelStage).map(stage => (
<option key={stage} value={stage}>{stage}</option>
))}
</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"
value={filters.origin}
onChange={(e) => handleFilterChange('origin', e.target.value)}
>
<option value="all">Todas Origens</option>
<option value="WhatsApp">WhatsApp</option>
<option value="Instagram">Instagram</option>
<option value="Website">Website</option>
<option value="LinkedIn">LinkedIn</option>
<option value="Indicação">Indicação</option>
</select>
</div>
{originDefs.length > 0 ? originDefs.map(o => (
<option key={o.id} value={o.name}>{o.name}</option>
)) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => (
<option key={o} value={o}>{o}</option>
))}
</select> </div>
</div>
</div>
@@ -397,10 +470,9 @@ export const Dashboard: React.FC = () => {
{originData.map((entry, index) => (
<Cell
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
formatter={(value: any) => [value, 'Leads']}
contentStyle={{

364
pages/Funnels.tsx Normal file
View File

@@ -0,0 +1,364 @@
import React, { useState, useEffect } from 'react';
import { Layers, Plus, Edit, Trash2, ChevronUp, ChevronDown, Loader2, X, Users } from 'lucide-react';
import { getFunnels, createFunnel, updateFunnel, deleteFunnel, createFunnelStage, updateFunnelStage, deleteFunnelStage, getTeams } from '../services/dataService';
import { FunnelDef, FunnelStageDef } from '../types';
export const Funnels: React.FC = () => {
const [funnels, setFunnels] = useState<FunnelDef[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [selectedFunnelId, setSelectedFunnelId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingStage, setEditingStage] = useState<FunnelStageDef | null>(null);
const [formData, setFormData] = useState({ name: '', color_class: '' });
const [isSaving, setIsSaving] = useState(false);
// Funnel creation state
const [isFunnelModalOpen, setIsFunnelModalOpen] = useState(false);
const [funnelName, setFunnelName] = useState('');
const tenantId = localStorage.getItem('ctms_tenant_id') || '';
const PRESET_COLORS = [
{ label: 'Cinza (Neutro)', value: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border' },
{ label: 'Azul (Início)', 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 (Meio)', value: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800' },
{ label: 'Laranja (Atenção)', value: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' },
{ label: 'Verde (Sucesso)', 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 (Perda)', value: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
];
const loadData = async () => {
setLoading(true);
const [fetchedFunnels, fetchedTeams] = await Promise.all([
getFunnels(tenantId),
getTeams(tenantId)
]);
setFunnels(fetchedFunnels);
setTeams(fetchedTeams);
if (!selectedFunnelId && fetchedFunnels.length > 0) {
setSelectedFunnelId(fetchedFunnels[0].id);
}
setLoading(false);
};
useEffect(() => {
loadData();
}, [tenantId]);
const selectedFunnel = funnels.find(f => f.id === selectedFunnelId);
// --- Funnel Handlers ---
const handleCreateFunnel = async (e: React.FormEvent) => {
e.preventDefault();
setIsSaving(true);
try {
const res = await createFunnel({ name: funnelName, tenantId });
setSelectedFunnelId(res.id);
setIsFunnelModalOpen(false);
setFunnelName('');
loadData();
} catch (err) {
alert("Erro ao criar funil.");
} finally {
setIsSaving(false);
}
};
const handleDeleteFunnel = async (id: string) => {
if (funnels.length <= 1) {
alert("Você precisa ter pelo menos um funil ativo.");
return;
}
if (confirm('Tem certeza que deseja excluir este funil e todas as suas etapas?')) {
await deleteFunnel(id);
setSelectedFunnelId(null);
loadData();
}
};
const handleToggleTeam = async (teamId: string) => {
if (!selectedFunnel) return;
const currentTeamIds = selectedFunnel.teamIds || [];
const newTeamIds = currentTeamIds.includes(teamId)
? currentTeamIds.filter(id => id !== teamId)
: [...currentTeamIds, teamId];
// Optimistic
const newFunnels = [...funnels];
const idx = newFunnels.findIndex(f => f.id === selectedFunnel.id);
newFunnels[idx].teamIds = newTeamIds;
setFunnels(newFunnels);
await updateFunnel(selectedFunnel.id, { teamIds: newTeamIds });
};
// --- Stage Handlers ---
const handleSaveStage = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFunnel) return;
setIsSaving(true);
try {
if (editingStage) {
await updateFunnelStage(editingStage.id, formData);
} else {
await createFunnelStage(selectedFunnel.id, { ...formData, order_index: selectedFunnel.stages.length });
}
setIsModalOpen(false);
loadData();
} catch (err) {
alert("Erro ao salvar etapa.");
} finally {
setIsSaving(false);
}
};
const handleDeleteStage = async (id: string) => {
if (confirm('Tem certeza que deseja excluir esta etapa?')) {
await deleteFunnelStage(id);
loadData();
}
};
const handleMoveStage = async (index: number, direction: 'up' | 'down') => {
if (!selectedFunnel) return;
const stages = selectedFunnel.stages;
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === stages.length - 1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
const tempOrder = stages[index].order_index;
stages[index].order_index = stages[newIndex].order_index;
stages[newIndex].order_index = tempOrder;
setFunnels([...funnels]); // trigger re-render
await Promise.all([
updateFunnelStage(stages[index].id, { order_index: stages[index].order_index }),
updateFunnelStage(stages[newIndex].id, { order_index: stages[newIndex].order_index })
]);
};
const openStageModal = (stage?: FunnelStageDef) => {
if (stage) {
setEditingStage(stage);
setFormData({ name: stage.name, color_class: stage.color_class });
} else {
setEditingStage(null);
setFormData({ name: '', color_class: PRESET_COLORS[0].value });
}
setIsModalOpen(true);
};
if (loading && funnels.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: Funnels 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">Gerenciar Funis</h2>
<button onClick={() => setIsFunnelModalOpen(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">
{funnels.map(f => (
<button
key={f.id}
onClick={() => setSelectedFunnelId(f.id)}
className={`text-left px-4 py-3 rounded-xl text-sm font-medium transition-all ${selectedFunnelId === f.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>{f.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${selectedFunnelId === f.id ? 'bg-white/20 dark:bg-black/20' : 'bg-zinc-100 dark:bg-dark-bg'}`}>{f.stages?.length || 0}</span>
</div>
</button>
))}
</div>
</div>
{/* Main Content: Selected Funnel Details */}
<div className="flex-1 space-y-6">
{selectedFunnel ? (
<>
<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">{selectedFunnel.name}</h1>
<p className="text-zinc-500 dark:text-zinc-400 text-sm">Gerencie as etapas deste funil e quais times o utilizam.</p>
</div>
<button onClick={() => handleDeleteFunnel(selectedFunnel.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 Funil
</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 = selectedFunnel.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 funil específico usarão o Funil Padrão.</p>
</div>
</div>
{/* Stages */}
<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">
<Layers className="text-brand-yellow" size={18} /> Etapas do Funil
</h3>
<button onClick={() => openStageModal()} className="text-sm font-bold text-brand-yellow hover:underline flex items-center gap-1">
<Plus size={16} /> Nova Etapa
</button>
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{selectedFunnel.stages?.sort((a,b) => a.order_index - b.order_index).map((f, index) => (
<div key={f.id} className="p-4 flex items-center justify-between group hover:bg-zinc-50 dark:hover:bg-dark-bg transition-colors">
<div className="flex items-center gap-4">
<div className="flex flex-col gap-1">
<button onClick={() => handleMoveStage(index, 'up')} disabled={index === 0} className="p-1 text-zinc-300 hover:text-zinc-900 dark:hover:text-white disabled:opacity-30 transition-colors">
<ChevronUp size={16} />
</button>
<button onClick={() => handleMoveStage(index, 'down')} disabled={index === selectedFunnel.stages.length - 1} className="p-1 text-zinc-300 hover:text-zinc-900 dark:hover:text-white disabled:opacity-30 transition-colors">
<ChevronDown size={16} />
</button>
</div>
<div>
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${f.color_class}`}>
{f.name}
</span>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => openStageModal(f)} 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={() => handleDeleteStage(f.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>
))}
{(!selectedFunnel.stages || selectedFunnel.stages.length === 0) && (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted">Nenhuma etapa configurada neste funil.</div>
)}
</div>
</div>
</>
) : (
<div className="p-12 text-center text-zinc-500">Selecione ou crie um funil.</div>
)}
</div>
{/* Funnel Creation Modal */}
{isFunnelModalOpen && (
<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 Funil</h3>
<button onClick={() => setIsFunnelModalOpen(false)} className="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"><X size={20} /></button>
</div>
<form onSubmit={handleCreateFunnel} 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 Funil</label>
<input
type="text"
value={funnelName}
onChange={e => setFunnelName(e.target.value)}
placeholder="Ex: Vendas 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={() => setIsFunnelModalOpen(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 || !funnelName.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 Funil'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Stage 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">{editingStage ? 'Editar Etapa' : 'Nova Etapa'}</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={handleSaveStage} 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 Etapa</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="Ex: Qualificação"
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 Etapa'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

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 { Hexagon, Lock, Mail, ArrowRight, Loader2, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { login, logout } from '../services/dataService';
@@ -12,6 +12,16 @@ export const Login: React.FC = () => {
const [error, setError] = 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) => {
setEmail(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,6 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, Edit2, X } from 'lucide-react';
import { getTeams, getUsers, getAttendances, createTeam, updateTeam, getUserById } from '../services/dataService';
import { Building2, Users, Plus, Search, Target, ArrowUpRight, Loader2, Edit2, X, Trash2 } from 'lucide-react';
import { getTeams, getUsers, getAttendances, createTeam, updateTeam, deleteTeam, getUserById } from '../services/dataService';
import { User, Attendance } from '../types';
export const Teams: React.FC = () => {
@@ -53,6 +53,12 @@ export const Teams: React.FC = () => {
} catch (err) { alert('Erro ao salvar'); } finally { setIsSaving(false); }
};
const handleDeleteTeam = async (id: string) => {
if (confirm('Tem certeza que deseja excluir este time? Todos os usuários deste time ficarão sem time atribuído.')) {
await deleteTeam(id);
loadData();
}
};
if (loading && teams.length === 0) return <div className="p-12 text-center text-zinc-400 dark:text-dark-muted transition-colors">Carregando...</div>;
const canManage = currentUser?.role === 'admin' || currentUser?.role === 'super_admin';
@@ -81,7 +87,10 @@ export const Teams: React.FC = () => {
<div className="flex justify-between mb-6">
<div className="p-3 bg-zinc-50 dark:bg-dark-bg text-zinc-600 dark:text-dark-muted rounded-xl border border-zinc-100 dark:border-dark-border"><Building2 size={24} /></div>
{canManage && (
<div className="flex items-center gap-2">
<button onClick={() => { setEditingTeam(t); setFormData({name:t.name, description:t.description||''}); setIsModalOpen(true); }} className="text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text p-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-dark-bg transition-all"><Edit2 size={18} /></button>
<button onClick={() => handleDeleteTeam(t.id)} className="text-zinc-400 dark:text-dark-muted hover:text-red-600 dark:hover:text-red-500 p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-all"><Trash2 size={18} /></button>
</div>
)}
</div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-50 mb-1">{t.name}</h3>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, Link } from 'react-router-dom';
import { getAttendances, getUserById } from '../services/dataService';
import { Attendance, User, FunnelStage, DashboardFilter } from '../types';
import { getAttendances, getUserById, getFunnels, getOrigins } from '../services/dataService';
import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef, OriginItemDef } from '../types';
import { ArrowLeft, Mail, Phone, Clock, MessageSquare, ChevronLeft, ChevronRight, Eye, Filter } from 'lucide-react';
import { DateRangePicker } from '../components/DateRangePicker';
@@ -11,6 +11,8 @@ export const UserDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [user, setUser] = useState<User | undefined>();
const [attendances, setAttendances] = useState<Attendance[]>([]);
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [originDefs, setOriginDefs] = useState<OriginItemDef[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [filters, setFilters] = useState<DashboardFilter>({
@@ -31,11 +33,30 @@ export const UserDetail: React.FC = () => {
setUser(u);
if (u && tenantId) {
const data = await getAttendances(tenantId, {
const [data, fetchedFunnels, fetchedOrigins] = await Promise.all([
getAttendances(tenantId, {
...filters,
userId: id
});
}),
getFunnels(tenantId),
getOrigins(tenantId)
]);
setAttendances(data);
const targetTeamId = u.team_id || null;
let activeFunnel = fetchedFunnels[0];
if (targetTeamId) {
const matchedFunnel = fetchedFunnels.find(f => f.teamIds?.includes(targetTeamId));
if (matchedFunnel) activeFunnel = matchedFunnel;
}
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) {
console.error("Error loading user details", error);
@@ -66,7 +87,10 @@ export const UserDetail: React.FC = () => {
}
};
const getStageBadgeColor = (stage: FunnelStage) => {
const getStageBadgeColor = (stage: string) => {
const def = funnelDefs.find(f => f.name === stage);
if (def) return def.color_class;
switch (stage) {
case FunnelStage.WON: return 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800';
case FunnelStage.LOST: return 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800';
@@ -133,24 +157,24 @@ export const UserDetail: React.FC = () => {
onChange={(e) => handleFilterChange('funnelStage', e.target.value)}
>
<option value="all">Todas Etapas</option>
{Object.values(FunnelStage).map(stage => (
{funnelDefs.length > 0 ? funnelDefs.map(stage => (
<option key={stage.id} value={stage.name}>{stage.name}</option>
)) : Object.values(FunnelStage).map(stage => (
<option key={stage} value={stage}>{stage}</option>
))}
</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"
value={filters.origin}
onChange={(e) => handleFilterChange('origin', e.target.value)}
>
<option value="all">Todas Origens</option>
<option value="WhatsApp">WhatsApp</option>
<option value="Instagram">Instagram</option>
<option value="Website">Website</option>
<option value="LinkedIn">LinkedIn</option>
<option value="Indicação">Indicação</option>
</select>
</div>
{originDefs.length > 0 ? originDefs.map(o => (
<option key={o.id} value={o.name}>{o.name}</option>
)) : ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'].map(o => (
<option key={o} value={o}>{o}</option>
))}
</select> </div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -198,16 +222,14 @@ export const UserDetail: React.FC = () => {
{currentData.map(att => (
<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">
<div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString()}</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="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('pt-BR', {hour: '2-digit', minute:'2-digit', hour12: false})}</div>
</td>
<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.summary}</span>
<span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.title}</span>
<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>
</div>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className={`inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getStageBadgeColor(att.funnel_stage)}`}>

View File

@@ -19,6 +19,7 @@ export const UserProfile: React.FC = () => {
useEffect(() => {
const fetchUserAndTenant = async () => {
const storedUserId = localStorage.getItem('ctms_user_id');
if (storedUserId) {
try {
const fetchedUser = await getUserById(storedUserId);
@@ -201,9 +202,17 @@ export const UserProfile: React.FC = () => {
id="fullName"
value={name}
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>
{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 className="space-y-2">

View File

@@ -1,9 +0,0 @@
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL was not found on this server.</p>
<p>Additionally, a 404 Not Found
error was encountered while trying to use an ErrorDocument to handle the request.</p>
</body></html>

View File

@@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types';
// Em desenvolvimento, aponta para o localhost:3001
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
const getHeaders = () => {
const token = localStorage.getItem('ctms_token');
const getHeaders = (customToken?: string) => {
const token = customToken || localStorage.getItem('ctms_token');
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
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[]> => {
try {
const response = await fetch(`${API_URL}/notifications`, {
const response = await apiFetch(`${API_URL}/notifications`, {
headers: getHeaders()
});
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> => {
try {
const response = await fetch(`${API_URL}/notifications/${id}`, {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'PUT',
headers: getHeaders()
});
@@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise<boolean> => {
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/notifications/read-all`, {
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
method: 'PUT',
headers: getHeaders()
});
@@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
export const deleteNotification = async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/notifications/${id}`, {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise<boolean> => {
export const clearAllNotifications = async (): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/notifications`, {
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -82,9 +149,237 @@ export const clearAllNotifications = async (): Promise<boolean> => {
}
};
// --- Funnels Functions ---
export const getFunnels = async (tenantId: string): Promise<any[]> => {
try {
const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Falha ao buscar funis');
return await response.json();
} catch (error) {
console.error("API Error (getFunnels):", error);
return [];
}
};
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await apiFetch(`${API_URL}/funnels`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar funil');
}
return await response.json();
};
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response.ok;
} catch (error) {
console.error("API Error (updateFunnel):", error);
return false;
}
};
export const deleteFunnel = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteFunnel):", error);
return false;
}
};
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar etapa');
}
return await response.json();
};
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response.ok;
} catch (error) {
console.error("API Error (updateFunnelStage):", error);
return false;
}
};
export const deleteFunnelStage = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteFunnelStage):", error);
return false;
}
};
// --- 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 ---
export const getApiKeys = async (tenantId: string): Promise<any[]> => {
try {
const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Falha ao buscar chaves');
return await response.json();
} catch (error) {
console.error("API Error (getApiKeys):", error);
return [];
}
};
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
const response = await apiFetch(`${API_URL}/api-keys`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Erro ao criar chave de API');
}
return await response.json();
};
export const deleteApiKey = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteApiKey):", error);
return false;
}
};
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
try {
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Search failed');
@@ -108,7 +403,7 @@ export const getAttendances = async (tenantId: string, filter: DashboardFilter):
if (filter.funnelStage && filter.funnelStage !== 'all') params.append('funnelStage', filter.funnelStage);
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()
});
@@ -129,7 +424,7 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
const params = new URLSearchParams();
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()
});
if (!response.ok) throw new Error('Falha ao buscar usuários');
@@ -143,25 +438,29 @@ export const getUsers = async (tenantId: string): Promise<User[]> => {
export const getUserById = async (id: string): Promise<User | undefined> => {
try {
const response = await fetch(`${API_URL}/users/${id}`, {
const response = await apiFetch(`${API_URL}/users/${id}`, {
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");
if (contentType && contentType.indexOf("application/json") !== -1) {
return await response.json();
}
return undefined;
} catch (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> => {
try {
const response = await fetch(`${API_URL}/users/${id}`, {
const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(userData)
@@ -185,7 +484,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
formData.append('avatar', file);
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',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
@@ -204,7 +503,7 @@ export const uploadAvatar = async (id: string, file: File): Promise<string | nul
export const createMember = async (userData: any): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/users`, {
const response = await apiFetch(`${API_URL}/users`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(userData)
@@ -224,7 +523,7 @@ export const createMember = async (userData: any): Promise<boolean> => {
export const deleteUser = async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/users/${id}`, {
const response = await apiFetch(`${API_URL}/users/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -237,7 +536,7 @@ export const deleteUser = async (id: string): Promise<boolean> => {
export const getAttendanceById = async (id: string): Promise<Attendance | undefined> => {
try {
const response = await fetch(`${API_URL}/attendances/${id}`, {
const response = await apiFetch(`${API_URL}/attendances/${id}`, {
headers: getHeaders()
});
if (!response.ok) return undefined;
@@ -255,7 +554,7 @@ export const getAttendanceById = async (id: string): Promise<Attendance | undefi
export const getTenants = async (): Promise<any[]> => {
try {
const response = await fetch(`${API_URL}/tenants`, {
const response = await apiFetch(`${API_URL}/tenants`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Falha ao buscar tenants');
@@ -273,7 +572,7 @@ export const getTenants = async (): Promise<any[]> => {
export const getTeams = async (tenantId: string): Promise<any[]> => {
try {
const response = await fetch(`${API_URL}/teams?tenantId=${tenantId}`, {
const response = await apiFetch(`${API_URL}/teams?tenantId=${tenantId}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Falha ao buscar equipes');
@@ -286,7 +585,7 @@ export const getTeams = async (tenantId: string): Promise<any[]> => {
export const createTeam = async (teamData: any): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/teams`, {
const response = await apiFetch(`${API_URL}/teams`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(teamData)
@@ -300,7 +599,7 @@ export const createTeam = async (teamData: any): Promise<boolean> => {
export const updateTeam = async (id: string, teamData: any): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/teams/${id}`, {
const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(teamData)
@@ -312,9 +611,22 @@ export const updateTeam = async (id: string, teamData: any): Promise<boolean> =>
}
};
export const deleteTeam = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/teams/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteTeam):", error);
return false;
}
};
export const createTenant = async (tenantData: any): Promise<{ success: boolean; message?: string }> => {
try {
const response = await fetch(`${API_URL}/tenants`, {
const response = await apiFetch(`${API_URL}/tenants`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(tenantData)
@@ -330,7 +642,7 @@ export const createTenant = async (tenantData: any): Promise<{ success: boolean;
export const updateTenant = async (id: string, tenantData: any): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/tenants/${id}`, {
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(tenantData)
@@ -344,7 +656,7 @@ export const updateTenant = async (id: string, tenantData: any): Promise<boolean
export const deleteTenant = async (id: string): Promise<boolean> => {
try {
const response = await fetch(`${API_URL}/tenants/${id}`, {
const response = await apiFetch(`${API_URL}/tenants/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -362,9 +674,23 @@ export let isReloadingForImpersonation = false;
export const logout = () => {
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_refresh_token');
localStorage.removeItem('ctms_user_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> => {
@@ -385,6 +711,7 @@ export const login = async (credentials: any): Promise<any> => {
const data = isJson ? await response.json() : null;
if (data && 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_tenant_id', data.user.tenant_id || '');
}
@@ -392,7 +719,7 @@ export const login = async (credentials: any): 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',
headers: getHeaders()
});
@@ -460,7 +787,7 @@ export const returnToSuperAdmin = (): 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
@@ -473,7 +800,7 @@ export const register = async (userData: 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -486,7 +813,7 @@ export const verifyCode = async (data: any): Promise<boolean> => {
};
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
@@ -505,7 +832,7 @@ export const forgotPassword = async (email: 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, token, name })

Binary file not shown.

8
test-b64.js Normal file
View File

@@ -0,0 +1,8 @@
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ";
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
console.log(base64);
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
console.log(JSON.parse(jsonPayload));

11
test-jwt.js Normal file
View File

@@ -0,0 +1,11 @@
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ";
const base64Url = token.split('.')[1];
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const pad = base64.length % 4;
if (pad) {
base64 += '='.repeat(4 - pad);
}
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
console.log(JSON.parse(jsonPayload));

15
test-mysql-error.cjs Normal file
View File

@@ -0,0 +1,15 @@
const mysql = require('mysql2/promise');
const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
async function run() {
try {
const id = 'u_71657ec7'; // or your ID
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [id, id]);
console.log(rows);
} catch (err) {
console.error(err);
} finally {
pool.end();
}
}
run();

14
test-mysql.cjs Normal file
View File

@@ -0,0 +1,14 @@
const mysql = require('mysql2/promise');
async function test() {
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']);
console.log("ROWS:", rows);
} catch (err) {
console.error("ERROR:", err);
} finally {
await pool.end();
}
}
test();

23
test-mysql.js Normal file
View File

@@ -0,0 +1,23 @@
const mysql = require('mysql2/promise');
async function test() {
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']);
console.log("ROWS:", rows);
// Simulate req.user
const req = { user: { role: 'super_admin', tenant_id: 'system' } };
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
console.log("Access Denied");
} else {
console.log("Access Granted");
}
} catch (err) {
console.error("ERROR:", err);
} finally {
await pool.end();
}
}
test();

View File

@@ -7,6 +7,37 @@ export enum FunnelStage {
LOST = 'Perdidos',
}
export interface FunnelStageDef {
id: string;
funnel_id: string;
name: string;
color_class: string;
order_index: number;
}
export interface FunnelDef {
id: string;
tenant_id: string;
name: string;
stages: FunnelStageDef[];
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 {
id: string;
tenant_id: string;
@@ -26,14 +57,15 @@ export interface Attendance {
tenant_id: string;
user_id: string;
created_at: string; // ISO Date
summary: string;
title: string;
full_summary?: string;
attention_points: string[];
improvement_points: string[];
score: number; // 0-100
first_response_time_min: number;
handling_time_min: number;
funnel_stage: FunnelStage;
origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação';
funnel_stage: string;
origin: string;
product_requested: string;
product_sold?: string;
converted: boolean;