Compare commits

...

58 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
Cauê Faleiros
1d49161a05 fix: block race condition causing logout during impersonation handoff
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m31s
- Introduced isReloadingForImpersonation flag to temporarily disable the logout function while tokens are being swapped before the hard reload.
2026-03-11 15:49:03 -03:00
Cauê Faleiros
bf157687d4 fix: resolve race conditions during impersonation handoff by reloading directly from dataService 2026-03-11 15:33:38 -03:00
Cauê Faleiros
7cb78f13c0 fix: pad base64 string when parsing jwt during impersonation exit
- Prevented the browser's atob() function from throwing a 'String contains an invalid character' exception by adding proper Base64 padding to the JWT payload before decoding.
2026-03-11 15:13:19 -03:00
Cauê Faleiros
684b98bd0e fix: resolve HashRouter reload issues during impersonation handoffs
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
- Updated Layout and SuperAdmin to explicitly set window.location.hash before triggering window.location.reload() to guarantee correct routing after state resets.
2026-03-11 14:54:35 -03:00
Cauê Faleiros
89f250a43b fix: correct redirect url when exiting impersonation mode
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m3s
- Now correctly routes the Super Admin back to /#/super-admin instead of the standard root dashboard.
2026-03-11 14:42:04 -03:00
Cauê Faleiros
671633b813 style: remove hover requirement for action buttons across all tables
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m6s
- Action buttons (Edit, Delete, Impersonate) are now permanently visible for better UX and discoverability.
2026-03-11 14:37:23 -03:00
Cauê Faleiros
bff54def9f fix: include missing files for tenant impersonation feature
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m7s
- Added backend impersonate endpoint.

- Added frontend impersonate button and functions.

- Fixed build failure by including missing exported functions in dataService.ts.
2026-03-11 14:16:41 -03:00
Cauê Faleiros
b7f9efd0d1 feat: implement tenant impersonation for super admins
Some checks failed
Build and Deploy / build-and-push (push) Failing after 1m38s
- Added POST /api/impersonate/:tenantId to generate a specialized tenant-scoped JWT.

- Added UI button in SuperAdmin page to trigger impersonation.

- Saved original super_admin token to localStorage to allow returning without re-login.

- Added 'Retornar ao Painel Central' button in sidebar to quickly revert to super admin status.
2026-03-10 16:20:06 -03:00
Cauê Faleiros
ee3b9f4ce6 feat: add loading animation to tenant creation button
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m10s
- Implemented isSaving state in SuperAdmin to provide visual feedback during organization creation and updates.
2026-03-10 15:10:44 -03:00
Cauê Faleiros
ab35cf9126 fix: resolve smtp authentication error and notification issues
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m12s
- Stripped literal quotes from SMTP credentials in nodemailer config to prevent '535 Incorrect auth data' in Docker Swarm.

- Reduced notification polling interval from 60s to 10s for real-time updates.

- Fixed browser autoplay block for audio notifications by properly initializing the audio context.
2026-03-10 14:37:24 -03:00
Cauê Faleiros
d3587344a3 fix: resolve notification sound autoplay block and polling delay
- Prevented sound from triggering on initial page load.

- Confirmed polling interval is set to 10 seconds for real-time alerts.
2026-03-10 11:09:03 -03:00
Cauê Faleiros
754c1e2a21 feat: add user preference for audio notifications and play sound on new alerts
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
- Added sound_enabled column to users table with a default of true.

- Implemented a pleasant pop sound (notification.mp3) that plays when a new unread notification arrives.

- Added a toggle in the User Profile page allowing users to enable/disable the sound.
2026-03-10 10:38:03 -03:00
Cauê Faleiros
ccbba312bb feat: implement persistent notification system
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m38s
- Added notifications table with auto-migration on startup.

- Created backend endpoints for fetching and managing notifications.

- Implemented interactive notification tray in the header with unread badges.

- Added automated triggers for organization creation and user registration completion.
2026-03-09 17:08:41 -03:00
Cauê Faleiros
ec7cb18928 feat: customize search placeholder based on user role
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m10s
- Agents see 'Buscar atendimentos...'

- Super admins see 'Buscar membros, equipes, atendimentos ou organizações...'

- Admin and managers see 'Buscar membros, equipes ou atendimentos...'
2026-03-09 16:29:41 -03:00
Cauê Faleiros
12d24e9255 feat: refine global search RBAC and fix image loading
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m24s
- Restricted Agent search to Attendances only.

- Enabled Super Admin search for Organizations (Tenants).

- Fixed user avatar URL construction in search results.

- Added Organizations category to search dropdown for Super Admins.
2026-03-09 16:09:41 -03:00
Cauê Faleiros
13bcfc1314 feat: enhance global search UI and positioning
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m55s
- Moved search bar to the left/center for better accessibility.

- Increased search bar width to max-w-2xl.

- Refined search results dropdown layout and styling.
2026-03-09 15:54:49 -03:00
Cauê Faleiros
c07967188a feat: implement categorized global search with RBAC
- Added /api/search endpoint with strict role-based data isolation.

- Created searchGlobal function in dataService.

- Refined header UI with an interactive, categorized search results dropdown.
2026-03-09 15:25:12 -03:00
Cauê Faleiros
000bc38712 fix: remove duplicated layout titles and use singular team terminology for managers
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m48s
2026-03-09 11:07:18 -03:00
Cauê Faleiros
56b1f0c884 fix: sanitize rbac error msg and enforce manager creation constraints
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m8s
- Prevented API error messages from leaking system roles.

- Updated POST /users to safely allow managers to create users while strictly forcing them to be agents assigned to the manager's team.
2026-03-09 10:15:16 -03:00
Cauê Faleiros
3481e698bc fix: change sidebar label to 'Meu Time' for managers
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m10s
2026-03-06 16:34:54 -03:00
Cauê Faleiros
0d3ce93e32 fix: populate slugs for old users and include slug/team_id in jwt token
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
2026-03-06 16:15:16 -03:00
Cauê Faleiros
feb98d830b fix: resolve sql query logic preventing managers from seeing themselves or their team if team_id is null
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m2s
2026-03-06 15:26:10 -03:00
30 changed files with 3646 additions and 485 deletions

175
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') {
setUser(fetchedUser);
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:

File diff suppressed because it is too large Load Diff

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,41 +8,95 @@ 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">
<input
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"
/>
<span className="text-zinc-400 dark:text-dark-muted">até</span>
<input
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"
/>
<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="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
<span className="text-zinc-400 dark:text-dark-muted font-normal text-xs">até</span>
{/* End Date */}
<div
className="relative cursor-pointer hover:text-brand-yellow transition-colors"
onClick={() => openPicker(endRef)}
>
{formatShortDate(dateRange.end)}
<input
ref={endRef}
type="date"
value={formatDateForInput(dateRange.end)}
onChange={handleEndChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden"
/>
</div>
</div>
</div>
);

View File

@@ -2,14 +2,21 @@ 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
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
ChevronLeft, ChevronRight, Key, Target
} from 'lucide-react';
import { getAttendances, getUsers, getUserById, logout } from '../services/dataService';
import {
getAttendances, getUsers, getUserById, logout, searchGlobal,
getNotifications, markNotificationAsRead, markAllNotificationsAsRead,
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
@@ -25,11 +32,98 @@ 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: [] });
const [isSearching, setIsSearching] = useState(false);
const [showSearchResults, setShowSearchResults] = useState(false);
// Notifications State
const [notifications, setNotifications] = useState<any[]>([]);
const [showNotifications, setShowNotifications] = useState(false);
const unreadCount = notifications.filter(n => !n.is_read).length;
const previousUnreadCountRef = React.useRef(0);
const isInitialLoadRef = React.useRef(true);
// Pre-initialize audio to ensure it's loaded and ready
const audioRef = React.useRef<HTMLAudioElement | null>(null);
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();
if (playPromise !== undefined) {
playPromise.catch(e => console.log('Audio play blocked by browser policy:', e));
}
}
};
const loadNotifications = async () => {
const data = await getNotifications();
const newUnreadCount = data.filter((n: any) => !n.is_read).length;
// Only play sound if it's NOT the first load AND the count actually increased
if (!isInitialLoadRef.current && newUnreadCount > previousUnreadCountRef.current) {
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) {
setIsSearching(true);
const results = await searchGlobal(searchQuery);
setSearchResults(results);
setIsSearching(false);
setShowSearchResults(true);
} else {
setSearchResults({ members: [], teams: [], attendances: [], organizations: [] });
setShowSearchResults(false);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [searchQuery]);
const getSearchPlaceholder = () => {
if (currentUser?.role === 'super_admin') return 'Buscar membros, equipes, atendimentos ou organizações...';
if (currentUser?.role === 'agent') return 'Buscar atendimentos...';
return 'Buscar membros, equipes ou atendimentos...';
};
useEffect(() => {
const fetchCurrentUser = async () => {
const storedUserId = localStorage.getItem('ctms_user_id');
@@ -50,6 +144,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
}
};
fetchCurrentUser();
loadNotifications();
const interval = setInterval(loadNotifications, 10000);
return () => clearInterval(interval);
}, [navigate]);
const handleLogout = () => {
@@ -89,81 +186,130 @@ 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">
<X size={24} />
</button>
{!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>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{/* Standard User Links */}
{!isSuperAdmin && (
<>
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={false} />
{currentUser.role !== 'agent' && (
<>
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={false} />
<SidebarItem to="/admin/teams" icon={Building2} label="Times" collapsed={false} />
</>
)}
</>
)}
{/* 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">
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>
{/* User Profile Mini - Now Clickable to Profile */}
<div className="p-4 border-t border-zinc-100 dark:border-dark-border">
<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
onClick={() => navigate('/profile')}
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
{/* 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"
>
<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"
/>
<ChevronRight size={18} />
</button>
</div>
)}
{/* Navigation */}
<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={isSidebarCollapsed} />
{currentUser.role !== 'agent' && (
<>
<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} />
</>
)}
</>
)}
</>
)}
{/* Super Admin Links */}
{isSuperAdmin && (
<>
{!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={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-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 ${isSidebarCollapsed ? 'px-0' : ''}`}
title="Retornar ao Painel Central"
>
{isSidebarCollapsed ? <LogOut size={16} /> : 'Retornar ao Painel Central'}
</button>
)}
<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 ${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 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">
{currentUser.role === 'super_admin' ? 'Super Admin' :
currentUser.role === 'admin' ? 'Administrador' :
currentUser.role === 'manager' ? 'Gerente' : 'Agente'}
currentUser.role === 'admin' ? 'Administrador' :
currentUser.role === 'manager' ? 'Gerente' : 'Agente'}
</p>
</div>
</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>
@@ -171,15 +317,159 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="h-20 bg-white dark:bg-dark-header border-b border-zinc-200 dark:border-dark-border px-4 sm:px-8 flex items-center justify-between z-10 sticky top-0 transition-colors">
<div className="flex items-center gap-4">
<header className="h-20 bg-white dark:bg-dark-header border-b border-zinc-200 dark:border-dark-border px-4 sm:px-8 flex items-center gap-4 sm:gap-8 z-10 sticky top-0 transition-colors">
<div className="flex items-center gap-4 shrink-0">
<button onClick={() => setIsMobileMenuOpen(true)} className="lg:hidden text-zinc-500 hover:text-zinc-900 dark:hover:text-white">
<Menu size={24} />
</button>
<h1 className="text-xl font-bold text-zinc-800 dark:text-dark-text hidden sm:block">{getPageTitle()}</h1>
</div>
<div className="flex items-center gap-4 sm:gap-6">
{/* Search Bar - Moved to left/center and made wider */}
<div className="hidden md:block relative flex-1 max-w-2xl">
<div className="flex items-center bg-zinc-100 dark:bg-dark-bg rounded-xl px-4 py-2.5 w-full border border-transparent focus-within:bg-white dark:focus-within:bg-dark-card focus-within:border-brand-yellow focus-within:ring-2 focus-within:ring-brand-yellow/20 dark:focus-within:ring-brand-yellow/10 transition-all">
{isSearching ? <Loader2 size={18} className="text-brand-yellow animate-spin" /> : <Search size={18} className="text-zinc-400 dark:text-dark-muted" />}
<input
type="text"
placeholder={getSearchPlaceholder()}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery.length >= 2 && setShowSearchResults(true)}
className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted"
/>
</div>
{/* Search Results Dropdown */}
{showSearchResults && (
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="max-h-[480px] overflow-y-auto p-2">
{/* Organizations Section (Super Admin only) */}
{searchResults.organizations && searchResults.organizations.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
{searchResults.organizations.map(o => (
<button
key={o.id}
onClick={() => {
navigate(`/super-admin`);
setShowSearchResults(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-brand-yellow border border-zinc-200 dark:border-dark-border">
<Hexagon size={18} fill="currentColor" />
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{o.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{o.slug} {o.status}</div>
</div>
</button>
))}
</div>
)}
{/* Members Section */}
{searchResults.members.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
{searchResults.members.map(m => {
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
const avatarSrc = m.avatar_url
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
return (
<button
key={m.id}
onClick={() => {
navigate(`/users/${m.slug || m.id}`);
setShowSearchResults(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<img
src={avatarSrc}
alt={m.name}
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
/>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{m.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted">{m.email}</div>
</div>
</button>
);
})}
</div>
)}
{/* Teams Section */}
{searchResults.teams.length > 0 && (
<div className="mb-4">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Equipes</div>
{searchResults.teams.map(t => (
<button
key={t.id}
onClick={() => {
navigate(`/admin/teams`);
setShowSearchResults(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted border border-zinc-200 dark:border-dark-border">
<Building2 size={18} />
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text">{t.name}</div>
<div className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-1">{t.description || 'Sem descrição'}</div>
</div>
</button>
))}
</div>
)}
{/* Attendances Section */}
{searchResults.attendances.length > 0 && (
<div className="mb-2">
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Atendimentos</div>
{searchResults.attendances.map(a => (
<button
key={a.id}
onClick={() => {
navigate(`/attendances/${a.id}`);
setShowSearchResults(false);
setSearchQuery('');
}}
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
>
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted text-[10px] font-bold border border-zinc-200 dark:border-dark-border">
KPI
</div>
<div className="flex-1 min-w-0">
<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('pt-BR')}</span>
</div>
</div>
</button>
))}
</div>
)}
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (!searchResults.organizations || searchResults.organizations.length === 0) && (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
Nenhum resultado encontrado para "{searchQuery}"
</div>
)}
</div>
</div>
)}
</div>
<div className="flex items-center gap-4 sm:gap-6 ml-auto shrink-0">
{/* Dark Mode Toggle */}
<button
onClick={toggleDarkMode}
@@ -189,23 +479,106 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{isDark ? <Sun size={20} /> : <Moon size={20} />}
</button>
{/* Search Bar */}
<div className="hidden md:flex items-center bg-zinc-100 dark:bg-dark-bg rounded-full px-4 py-2 w-64 border border-transparent focus-within:bg-white dark:focus-within:bg-dark-card focus-within:border-brand-yellow focus-within:ring-2 focus-within:ring-brand-yellow/20 dark:focus-within:ring-brand-yellow/10 transition-all">
<Search size={18} className="text-zinc-400 dark:text-dark-muted" />
<input
type="text"
placeholder="Buscar..."
className="bg-transparent border-none outline-none text-sm ml-2 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted"
/>
</div>
{/* Notifications */}
<div className="relative">
<button 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} />
<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>
<button
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} />
{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>
)}
</button>
{showNotifications && (
<div className="absolute top-full mt-2 right-0 w-80 bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="p-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-dark-text">Notificações</h3>
<div className="flex gap-3">
{unreadCount > 0 && (
<button
onClick={async (e) => {
e.stopPropagation();
await markAllNotificationsAsRead();
loadNotifications();
}}
className="text-xs text-brand-yellow hover:underline"
>
Marcar lidas
</button>
)}
{notifications.length > 0 && (
<button
onClick={async (e) => {
e.stopPropagation();
await clearAllNotifications();
loadNotifications();
}}
className="text-xs text-zinc-400 hover:text-red-500 hover:underline transition-colors"
>
Limpar tudo
</button>
)}
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length > 0 ? (
notifications.map(n => (
<div
key={n.id}
className={`w-full relative group p-4 text-left hover:bg-zinc-50 dark:hover:bg-dark-border transition-colors border-b border-zinc-50 dark:border-dark-border/50 last:border-0 ${!n.is_read ? 'bg-brand-yellow/5 dark:bg-brand-yellow/5' : ''}`}
>
<div
className="cursor-pointer pr-6"
onClick={async () => {
if (!n.is_read) await markNotificationAsRead(n.id);
if (n.link) navigate(n.link);
setShowNotifications(false);
loadNotifications();
}}
>
<div className="flex justify-between items-start mb-1">
<span className={`text-xs font-bold uppercase tracking-wider ${
n.type === 'success' ? 'text-green-500' :
n.type === 'warning' ? 'text-orange-500' :
n.type === 'error' ? 'text-red-500' : 'text-blue-500'
}`}>
{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('pt-BR')}
</span>
</div>
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
<p className="text-xs text-zinc-500 dark:text-dark-muted line-clamp-2">{n.message}</p>
</div>
{/* Delete Button */}
<button
onClick={async (e) => {
e.stopPropagation();
await deleteNotification(n.id);
loadNotifications();
}}
className="absolute top-4 right-4 p-1 text-zinc-300 hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/30"
title="Remover notificação"
>
<X size={14} />
</button>
</div>
))
) : (
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
Nenhuma notificação por enquanto.
</div>
)}
</div>
</div>
)}
</div>
{/* Close notifications when clicking outside */}
{showNotifications && <div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />}
</div>
</header>
@@ -217,11 +590,18 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{/* Overlay for mobile */}
{isMobileMenuOpen && (
<div
<div
className="fixed inset-0 bg-zinc-950/50 z-40 lg:hidden"
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>.
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 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'}.
</>
)}
</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(() => {
@@ -266,30 +339,30 @@ export const Dashboard: React.FC = () => {
</>
)}
<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.funnelStage}
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
<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>
@@ -395,12 +468,11 @@ export const Dashboard: React.FC = () => {
stroke="none"
>
{originData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS.origins[entry.name as keyof typeof COLORS.origins] || COLORS.charts[index % COLORS.charts.length]}
<Cell
key={`cell-${index}`}
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,14 +1,16 @@
import React, { useState, useMemo } from 'react';
import {
Building2, Users, MessageSquare, Plus, Search,
Edit, Trash2, ChevronDown, ChevronUp, ChevronsUpDown, X, CheckCircle2
import { useNavigate } from 'react-router-dom';
import {
Building2, Users, MessageSquare, Plus, Search,
Edit, Trash2, ChevronDown, ChevronUp, ChevronsUpDown, X, CheckCircle2, Loader2, LogIn
} from 'lucide-react';
import { getTenants, createTenant, deleteTenant, updateTenant } from '../services/dataService';
import { getTenants, createTenant, deleteTenant, updateTenant, impersonateTenant } from '../services/dataService';
import { Tenant } from '../types';
import { DateRangePicker } from '../components/DateRangePicker';
import { KPICard } from '../components/KPICard';
export const SuperAdmin: React.FC = () => {
const navigate = useNavigate();
const [dateRange, setDateRange] = useState({
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
end: new Date()
@@ -91,44 +93,67 @@ export const SuperAdmin: React.FC = () => {
}
};
const handleImpersonate = async (tenantId: string) => {
try {
if (tenantId === 'system') {
alert('Você já está na organização do sistema.');
return;
}
await impersonateTenant(tenantId);
} catch (err: any) {
alert(err.message || 'Erro ao tentar entrar na organização.');
}
};
const [successMessage, setSuccessMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const handleSaveTenant = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage('');
setSuccessMessage('');
setIsSaving(true);
const form = e.target as HTMLFormElement;
const name = (form.elements.namedItem('name') as HTMLInputElement).value;
const slug = (form.elements.namedItem('slug') as HTMLInputElement).value;
const admin_email = (form.elements.namedItem('admin_email') as HTMLInputElement).value;
const status = (form.elements.namedItem('status') as HTMLSelectElement).value;
if (editingTenant) {
const success = await updateTenant(editingTenant.id, { name, slug, admin_email, status });
if (success) {
setSuccessMessage('Organização atualizada com sucesso!');
loadTenants();
setTimeout(() => {
setIsModalOpen(false);
setSuccessMessage('');
setEditingTenant(null);
}, 2000);
try {
if (editingTenant) {
const success = await updateTenant(editingTenant.id, { name, slug, admin_email, status });
if (success) {
setSuccessMessage('Organização atualizada com sucesso!');
loadTenants();
setTimeout(() => {
setIsModalOpen(false);
setSuccessMessage('');
setEditingTenant(null);
setIsSaving(false);
}, 2000);
} else {
setErrorMessage('Erro ao atualizar organização.');
setIsSaving(false);
}
} else {
setErrorMessage('Erro ao atualizar organização.');
}
} else {
const result = await createTenant({ name, slug, admin_email, status });
if (result.success) {
setSuccessMessage(result.message || 'Organização criada com sucesso!');
loadTenants();
setTimeout(() => {
setIsModalOpen(false);
setSuccessMessage('');
}, 3000);
} else {
setErrorMessage(result.message || 'Erro ao salvar organização.');
const result = await createTenant({ name, slug, admin_email, status });
if (result.success) {
setSuccessMessage(result.message || 'Organização criada com sucesso!');
loadTenants();
setTimeout(() => {
setIsModalOpen(false);
setSuccessMessage('');
setIsSaving(false);
}, 3000);
} else {
setErrorMessage(result.message || 'Erro ao salvar organização.');
setIsSaving(false);
}
}
} catch (err) {
setErrorMessage('Ocorreu um erro inesperado.');
setIsSaving(false);
}
};
@@ -221,9 +246,14 @@ export const SuperAdmin: React.FC = () => {
<td className="px-6 py-4 text-center font-medium text-zinc-700 dark:text-zinc-300">{tenant.user_count}</td>
<td className="px-6 py-4 text-center font-medium text-zinc-700 dark:text-zinc-300">{tenant.attendance_count?.toLocaleString()}</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleEdit(tenant)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-50 dark:hover:bg-dark-bg rounded-lg transition-colors"><Edit size={16} /></button>
<button onClick={() => handleDelete(tenant.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 className="flex items-center justify-end gap-2 transition-opacity">
{tenant.id !== 'system' && (
<button onClick={() => handleImpersonate(tenant.id)} title="Entrar na Organização" className="p-2 text-zinc-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/30 rounded-lg transition-colors">
<LogIn size={16} />
</button>
)}
<button onClick={() => handleEdit(tenant)} title="Editar" className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-50 dark:hover:bg-dark-bg rounded-lg transition-colors"><Edit size={16} /></button>
<button onClick={() => handleDelete(tenant.id)} title="Excluir" 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>
</td>
</tr>
@@ -282,7 +312,9 @@ export const SuperAdmin: React.FC = () => {
</div>
<div className="pt-4 flex justify-end gap-3 border-t dark:border-dark-border mt-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" className="px-4 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold hover:opacity-90 transition-all shadow-sm">{editingTenant ? 'Salvar Alterações' : 'Criar Organização'}</button>
<button type="submit" disabled={isSaving} className="px-4 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} /> : (editingTenant ? 'Salvar Alterações' : 'Criar Organização')}
</button>
</div>
</form>
</div>

View File

@@ -194,7 +194,7 @@ export const TeamManagement: React.FC = () => {
</td>
{canManage && (
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex justify-end gap-2 transition-opacity">
<button onClick={() => { setEditingUser(user); setFormData({name:user.name, email:user.email, role:user.role as any, team_id:user.team_id||'', status:user.status as any, tenant_id: user.tenant_id||''}); setIsModalOpen(true); }} className="p-2 hover:bg-zinc-100 dark:hover:bg-dark-border text-zinc-400 hover:text-zinc-900 dark:hover:text-dark-text rounded-lg transition-colors"><Edit size={16} /></button>
<button onClick={() => { setUserToDelete(user); setDeleteConfirmName(''); setIsDeleteModalOpen(true); }} className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 text-zinc-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg transition-colors"><Trash2 size={16} /></button>
</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 = () => {
@@ -46,13 +46,19 @@ export const Teams: React.FC = () => {
setIsSaving(true);
try {
const tid = localStorage.getItem('ctms_tenant_id') || '';
const success = editingTeam
const success = editingTeam
? await updateTeam(editingTeam.id, formData)
: await createTeam({ ...formData, tenantId: tid });
if (success) { setIsModalOpen(false); loadData(); }
} 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';
@@ -61,8 +67,12 @@ export const Teams: React.FC = () => {
<div className="space-y-8 max-w-7xl mx-auto pb-12 transition-colors duration-300">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">Times</h1>
<p className="text-zinc-500 dark:text-dark-muted text-sm">Visualize o desempenho e gerencie seus grupos de vendas.</p>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 tracking-tight">
{currentUser?.role === 'manager' ? 'Meu Time' : 'Times'}
</h1>
<p className="text-zinc-500 dark:text-dark-muted text-sm">
{currentUser?.role === 'manager' ? 'Visualize o desempenho do seu grupo de vendas.' : 'Visualize o desempenho e gerencie seus grupos de vendas.'}
</p>
</div>
{canManage && (
<button onClick={() => { setEditingTeam(null); setFormData({name:'', description:''}); setIsModalOpen(true); }} className="bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 px-4 py-2 rounded-lg flex items-center gap-2 text-sm font-bold shadow-sm hover:opacity-90 transition-all">
@@ -77,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 && (
<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 opacity-0 group-hover:opacity-100 p-2 rounded-lg hover:bg-zinc-50 dark:hover:bg-dark-bg transition-all"><Edit2 size={18} /></button>
<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, {
...filters,
userId: id
});
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';
@@ -127,30 +151,30 @@ export const UserDetail: React.FC = () => {
onChange={(range) => handleFilterChange('dateRange', range)}
/>
<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.funnelStage}
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
<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,15 +222,13 @@ 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>
<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>
<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>
</td>
<td className="px-6 py-4 text-center">

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2 } from 'lucide-react';
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
import { User, Tenant } from '../types';
@@ -15,10 +15,11 @@ export const UserProfile: React.FC = () => {
const [name, setName] = useState('');
const [bio, setBio] = useState('');
const [email, setEmail] = useState('');
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">
@@ -264,6 +273,32 @@ export const UserProfile: React.FC = () => {
<p className="text-xs text-zinc-400 dark:text-zinc-500 text-right">{bio.length}/500 caracteres</p>
</div>
<div className="space-y-2 pt-2">
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-900/50 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div>
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 flex items-center gap-2">
<Bell size={16} className="text-brand-yellow" /> Notificações Sonoras
</h4>
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
Reproduzir um som quando você receber uma nova notificação.
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={user.sound_enabled ?? true}
onChange={async (e) => {
const newStatus = e.target.checked;
setUser({...user, sound_enabled: newStatus});
await updateUser(user.id, { sound_enabled: newStatus });
}}
/>
<div className="w-11 h-6 bg-zinc-200 peer-focus:outline-none rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-zinc-600 peer-checked:bg-brand-yellow"></div>
</label>
</div>
</div>
<div className="pt-4 flex items-center justify-end border-t border-zinc-100 dark:border-zinc-800 mt-6 transition-colors">
<button
type="button"

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,6 +17,379 @@ 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 apiFetch(`${API_URL}/notifications`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Failed to fetch notifications');
return await response.json();
} catch (error) {
console.error("API Error (getNotifications):", error);
return [];
}
};
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'PUT',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (markNotificationAsRead):", error);
return false;
}
};
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
method: 'PUT',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (markAllNotificationsAsRead):", error);
return false;
}
};
export const deleteNotification = async (id: string): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (deleteNotification):", error);
return false;
}
};
export const clearAllNotifications = async (): Promise<boolean> => {
try {
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
method: 'DELETE',
headers: getHeaders()
});
return response.ok;
} catch (error) {
console.error("API Error (clearAllNotifications):", error);
return false;
}
};
// --- 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 apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
headers: getHeaders()
});
if (!response.ok) throw new Error('Search failed');
return await response.json();
} catch (error) {
console.error("API Error (searchGlobal):", error);
return { members: [], teams: [], attendances: [], organizations: [] };
}
};
export const getAttendances = async (tenantId: string, filter: DashboardFilter): Promise<Attendance[]> => {
try {
const params = new URLSearchParams();
@@ -30,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()
});
@@ -51,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');
@@ -65,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)
@@ -107,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}` } : {})
@@ -126,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)
@@ -146,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()
});
@@ -159,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;
@@ -177,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');
@@ -195,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');
@@ -208,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)
@@ -222,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)
@@ -234,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)
@@ -252,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)
@@ -266,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()
});
@@ -279,10 +669,28 @@ export const deleteTenant = async (id: string): Promise<boolean> => {
// --- Auth Functions ---
// Flag to prevent background fetches from throwing 401 and logging out during impersonation handoffs
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> => {
@@ -303,14 +711,83 @@ 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 || '');
}
return data;
};
export const impersonateTenant = async (tenantId: string): Promise<any> => {
const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
method: 'POST',
headers: getHeaders()
});
const contentType = response.headers.get("content-type");
const isJson = contentType && contentType.indexOf("application/json") !== -1;
if (!response.ok) {
const errorData = isJson ? await response.json() : { error: 'Erro no servidor' };
throw new Error(errorData.error || 'Erro ao assumir identidade');
}
isReloadingForImpersonation = true; // Block logouts
const data = await response.json();
const oldToken = localStorage.getItem('ctms_token');
if (oldToken) {
localStorage.setItem('ctms_super_admin_token', oldToken);
}
localStorage.setItem('ctms_token', data.token);
localStorage.setItem('ctms_user_id', data.user.id);
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
window.location.hash = '#/';
window.location.reload();
return data;
};
export const returnToSuperAdmin = (): boolean => {
const superAdminToken = localStorage.getItem('ctms_super_admin_token');
if (superAdminToken) {
try {
isReloadingForImpersonation = true; // Block logouts
// Correctly decode Base64Url JWT payload with proper padding
const base64Url = superAdminToken.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(''));
const payload = JSON.parse(jsonPayload);
localStorage.setItem('ctms_token', superAdminToken);
localStorage.setItem('ctms_user_id', payload.id);
localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system');
localStorage.removeItem('ctms_super_admin_token');
window.location.hash = '#/super-admin';
window.location.reload();
return true;
} catch (e) {
isReloadingForImpersonation = false;
console.error("Failed to restore super admin token", e);
return false;
}
}
return false;
};
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)
@@ -323,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)
@@ -336,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 })
@@ -355,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;
@@ -18,6 +49,7 @@ export interface User {
team_id: string;
bio?: string;
status: 'active' | 'inactive';
sound_enabled?: boolean;
}
export interface Attendance {
@@ -25,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;