Compare commits
30 Commits
76c974bcd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509ed4a0d9 | ||
| 07cb43d0e3 | |||
|
|
b8541c0d24 | ||
|
|
062864364a | ||
|
|
d148984028 | ||
|
|
fa683ab28c | ||
|
|
83e6da2d56 | ||
|
|
4dbd7c62cd | ||
|
|
b5c8e97701 | ||
|
|
f65ff97434 | ||
|
|
958a2cdbd9 | ||
|
|
eb483f903b | ||
|
|
9ffcfcdcc8 | ||
|
|
3663d03cb9 | ||
|
|
2317c46ac9 | ||
|
|
4489f0a74d | ||
|
|
327ad064a4 | ||
|
|
8f7e5ee487 | ||
|
|
f11db95a2f | ||
|
|
1d3315a1d0 | ||
|
|
64c4ca8fb5 | ||
|
|
47799990e3 | ||
|
|
22a1228a60 | ||
|
|
f884f6dc3c | ||
|
|
a6686c6f7c | ||
|
|
96cfb3d125 | ||
|
|
baa1bd66f6 | ||
|
|
fbf3edb7a1 | ||
|
|
ef6d1582b3 | ||
|
|
2ae0e9fdac |
177
App.tsx
177
App.tsx
@@ -1,32 +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 { Funnels } from './pages/Funnels';
|
||||
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;
|
||||
@@ -34,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);
|
||||
@@ -52,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) {
|
||||
@@ -63,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>;
|
||||
};
|
||||
|
||||
@@ -74,14 +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="/admin/funnels" element={<AuthGuard roles={['super_admin', 'admin', 'manager']}><Funnels /></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>
|
||||
|
||||
53
GEMINI.md
53
GEMINI.md
@@ -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:
|
||||
|
||||
579
backend/index.js
579
backend/index.js
@@ -101,23 +101,52 @@ app.use('/uploads', express.static(uploadDir, {
|
||||
const apiRouter = express.Router();
|
||||
|
||||
// Middleware de autenticação
|
||||
const authenticateToken = (req, res, next) => {
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Ignorar rotas de auth
|
||||
if (req.path.startsWith('/auth/')) return next();
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
// API Key Authentication for n8n/External Integrations
|
||||
if (authHeader && authHeader.startsWith('Bearer fasto_sk_')) {
|
||||
const apiKey = authHeader.split(' ')[1];
|
||||
try {
|
||||
const [keys] = await pool.query('SELECT * FROM api_keys WHERE secret_key = ?', [apiKey]);
|
||||
if (keys.length === 0) return res.status(401).json({ error: 'Chave de API inválida.' });
|
||||
|
||||
// Update last used timestamp
|
||||
await pool.query('UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?', [keys[0].id]);
|
||||
|
||||
// Attach a "system/bot" user identity to the request based on the tenant
|
||||
req.user = {
|
||||
id: 'bot_integration',
|
||||
tenant_id: keys[0].tenant_id,
|
||||
role: 'admin', // Give integration admin privileges within its tenant
|
||||
is_api_key: true
|
||||
};
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error('API Key validation error:', error);
|
||||
return res.status(500).json({ error: 'Erro ao validar chave de API.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Standard JWT Authentication
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) return res.status(401).json({ error: 'Token não fornecido.' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) return res.status(403).json({ error: 'Token inválido ou expirado.' });
|
||||
try {
|
||||
const user = jwt.verify(token, JWT_SECRET);
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Token inválido ou expirado.' });
|
||||
}
|
||||
};
|
||||
|
||||
const requireRole = (roles) => (req, res, next) => {
|
||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado.' });
|
||||
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Acesso negado. Você não tem permissão para realizar esta ação.' });
|
||||
next();
|
||||
};
|
||||
@@ -180,8 +209,7 @@ apiRouter.post('/auth/verify', async (req, res) => {
|
||||
await connection.query('INSERT INTO tenants (id, name, slug, admin_email) VALUES (?, ?, ?, ?)',
|
||||
[tenantId, data.organization_name, data.organization_name.toLowerCase().replace(/ /g, '-'), email]);
|
||||
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, tenantId, data.full_name, email, data.password_hash, 'owner']);
|
||||
await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
||||
[userId, tenantId, data.full_name, email, data.password_hash, 'admin']); await connection.query('DELETE FROM pending_registrations WHERE email = ?', [email]);
|
||||
|
||||
await connection.commit();
|
||||
res.json({ message: 'Sucesso.' });
|
||||
@@ -210,8 +238,78 @@ apiRouter.post('/auth/login', async (req, res) => {
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Credenciais inválidas.' });
|
||||
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.json({ token, user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug } });
|
||||
// Generate Access Token (short-lived)
|
||||
const token = jwt.sign({ id: user.id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug }, JWT_SECRET, { expiresIn: '15m' });
|
||||
|
||||
// Generate Refresh Token (long-lived)
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex');
|
||||
const refreshId = `rt_${crypto.randomUUID().split('-')[0]}`;
|
||||
|
||||
// Store Refresh Token in database (expires in 30 days)
|
||||
await pool.query(
|
||||
'INSERT INTO refresh_tokens (id, user_id, token, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
|
||||
[refreshId, user.id, refreshToken]
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
refreshToken,
|
||||
user: { id: user.id, name: user.name, email: user.email, role: user.role, tenant_id: user.tenant_id, team_id: user.team_id, slug: user.slug }
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh Token
|
||||
apiRouter.post('/auth/refresh', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(400).json({ error: 'Refresh token não fornecido.' });
|
||||
|
||||
try {
|
||||
// Verifies if the token exists and hasn't expired
|
||||
const [tokens] = await pool.query(
|
||||
'SELECT r.user_id, u.tenant_id, u.role, u.team_id, u.slug, u.status FROM refresh_tokens r JOIN users u ON r.user_id = u.id WHERE r.token = ? AND r.expires_at > NOW()',
|
||||
[refreshToken]
|
||||
);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
// If invalid, optionally delete the bad token if it's just expired
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(401).json({ error: 'Sessão expirada. Faça login novamente.' });
|
||||
}
|
||||
|
||||
const user = tokens[0];
|
||||
|
||||
if (user.status !== 'active') {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
return res.status(403).json({ error: 'Sua conta está inativa.' });
|
||||
}
|
||||
|
||||
// Sliding Expiration: Extend the refresh token's life by another 30 days
|
||||
await pool.query('UPDATE refresh_tokens SET expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY) WHERE token = ?', [refreshToken]);
|
||||
|
||||
// Issue a new short-lived access token
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.user_id, tenant_id: user.tenant_id, role: user.role, team_id: user.team_id, slug: user.slug },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
|
||||
res.json({ token: newAccessToken });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (Revoke Refresh Token)
|
||||
apiRouter.post('/auth/logout', async (req, res) => {
|
||||
const { refreshToken } = req.body;
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await pool.query('DELETE FROM refresh_tokens WHERE token = ?', [refreshToken]);
|
||||
}
|
||||
res.json({ message: 'Logout bem-sucedido.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -305,6 +403,17 @@ apiRouter.post('/auth/reset-password', async (req, res) => {
|
||||
[crypto.randomUUID(), n.id, 'info', 'Novo Membro Ativo', `${name} concluiu o cadastro e já pode acessar o sistema.`, `/users/${user.id}`]
|
||||
);
|
||||
}
|
||||
|
||||
// If the new user is an admin, notify super_admins too
|
||||
if (user.role === 'admin') {
|
||||
const [superAdmins] = await pool.query("SELECT id FROM users WHERE role = 'super_admin'");
|
||||
for (const sa of superAdmins) {
|
||||
await pool.query(
|
||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), sa.id, 'success', 'Admin Ativo', `O admin ${name} da organização configurou sua conta.`, `/super-admin`]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard password reset, just update the hash
|
||||
@@ -351,7 +460,7 @@ apiRouter.get('/users/:idOrSlug', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
|
||||
if (!rows || rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
if (!req.user) return res.status(401).json({ error: 'Não autenticado' });
|
||||
if (!req.user || !req.user.role) return res.status(401).json({ error: 'Não autenticado' });
|
||||
|
||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
@@ -434,7 +543,8 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const isSelf = req.user.id === req.params.id;
|
||||
const isManagerOrAdmin = ['admin', 'owner', 'manager', 'super_admin'].includes(req.user.role);
|
||||
const isManagerOrAdmin = ['admin', 'manager', 'super_admin'].includes(req.user.role);
|
||||
const isAdmin = ['admin', 'super_admin'].includes(req.user.role);
|
||||
|
||||
if (!isSelf && !isManagerOrAdmin) {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
@@ -444,8 +554,9 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Acesso negado.' });
|
||||
}
|
||||
|
||||
const finalRole = isManagerOrAdmin && role !== undefined ? role : existing[0].role;
|
||||
const finalTeamId = isManagerOrAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||
// Only Admins can change roles and teams. Managers can only edit basic info of their team members.
|
||||
const finalRole = isAdmin && role !== undefined ? role : existing[0].role;
|
||||
const finalTeamId = isAdmin && team_id !== undefined ? team_id : existing[0].team_id;
|
||||
const finalStatus = isManagerOrAdmin && status !== undefined ? status : existing[0].status;
|
||||
const finalEmail = email !== undefined ? email : existing[0].email;
|
||||
const finalSoundEnabled = isSelf && sound_enabled !== undefined ? sound_enabled : (existing[0].sound_enabled ?? true);
|
||||
@@ -459,13 +570,25 @@ apiRouter.put('/users/:id', async (req, res) => {
|
||||
'UPDATE users SET name = ?, bio = ?, email = ?, role = ?, team_id = ?, status = ?, sound_enabled = ? WHERE id = ?',
|
||||
[name || existing[0].name, bio !== undefined ? bio : existing[0].bio, finalEmail, finalRole, finalTeamId || null, finalStatus, finalSoundEnabled, req.params.id]
|
||||
);
|
||||
|
||||
// Trigger Notification for Team Change
|
||||
if (finalTeamId && finalTeamId !== existing[0].team_id && existing[0].status === 'active') {
|
||||
const [team] = await pool.query('SELECT name FROM teams WHERE id = ?', [finalTeamId]);
|
||||
if (team.length > 0) {
|
||||
await pool.query(
|
||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), req.params.id, 'info', 'Novo Time', `Você foi adicionado ao time ${team[0].name}.`, '/']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'User updated successfully.' });
|
||||
} catch (error) { console.error('Update user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/users/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/users/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM users WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -563,6 +686,134 @@ apiRouter.delete('/notifications/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Origin Routes (Groups & Items) ---
|
||||
apiRouter.get('/origins', async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.query;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
||||
|
||||
const [groups] = await pool.query('SELECT * FROM origin_groups WHERE tenant_id = ? ORDER BY created_at ASC', [effectiveTenantId]);
|
||||
|
||||
// Seed default origin group if none exists
|
||||
if (groups.length === 0) {
|
||||
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']);
|
||||
|
||||
const defaultOrigins = [
|
||||
{ name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
|
||||
{ name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
|
||||
{ name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
|
||||
{ name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
|
||||
{ name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' }
|
||||
];
|
||||
for (const origin of defaultOrigins) {
|
||||
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
||||
[oid, gid, origin.name, origin.color]
|
||||
);
|
||||
}
|
||||
|
||||
// Update all teams of this tenant to use this origin group if they have none
|
||||
await pool.query('UPDATE teams SET origin_group_id = ? WHERE tenant_id = ? AND origin_group_id IS NULL', [gid, effectiveTenantId]);
|
||||
|
||||
groups.push({ id: gid, tenant_id: effectiveTenantId, name: 'Origens Padrão' });
|
||||
}
|
||||
|
||||
const [items] = await pool.query('SELECT * FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
||||
const [teams] = await pool.query('SELECT id, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [effectiveTenantId]);
|
||||
|
||||
const result = groups.map(g => ({
|
||||
...g,
|
||||
items: items.filter(i => i.origin_group_id === g.id),
|
||||
teamIds: teams.filter(t => t.origin_group_id === g.id).map(t => t.id)
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error("GET /origins error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/origins', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, name]);
|
||||
res.status(201).json({ id: gid });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, teamIds } = req.body;
|
||||
try {
|
||||
if (name) {
|
||||
await pool.query('UPDATE origin_groups SET name = ? WHERE id = ?', [name, req.params.id]);
|
||||
}
|
||||
if (teamIds && Array.isArray(teamIds)) {
|
||||
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
||||
if (teamIds.length > 0) {
|
||||
await pool.query('UPDATE teams SET origin_group_id = ? WHERE id IN (?)', [req.params.id, teamIds]);
|
||||
}
|
||||
}
|
||||
res.json({ message: 'Origin group updated.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/origins/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE origin_group_id = ?', [req.params.id]);
|
||||
await pool.query('UPDATE teams SET origin_group_id = NULL WHERE origin_group_id = ?', [req.params.id]);
|
||||
await pool.query('DELETE FROM origin_groups WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Origin group deleted.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/origins/:id/items', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
|
||||
[oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
|
||||
);
|
||||
res.status(201).json({ id: oid });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' });
|
||||
|
||||
await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]);
|
||||
res.json({ message: 'Origin item updated.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/origin_items/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM origin_items WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Origin item deleted.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Funnel Routes ---
|
||||
apiRouter.get('/funnels', async (req, res) => {
|
||||
try {
|
||||
@@ -615,7 +866,7 @@ apiRouter.get('/funnels', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/funnels', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -627,7 +878,7 @@ apiRouter.post('/funnels', requireRole(['admin', 'owner', 'manager', 'super_admi
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, teamIds } = req.body;
|
||||
try {
|
||||
if (name) {
|
||||
@@ -645,7 +896,7 @@ apiRouter.put('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_a
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/funnels/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM funnel_stages WHERE funnel_id = ?', [req.params.id]);
|
||||
await pool.query('UPDATE teams SET funnel_id = NULL WHERE funnel_id = ?', [req.params.id]);
|
||||
@@ -656,7 +907,7 @@ apiRouter.delete('/funnels/:id', requireRole(['admin', 'owner', 'manager', 'supe
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class, order_index } = req.body;
|
||||
try {
|
||||
const sid = `stage_${crypto.randomUUID().split('-')[0]}`;
|
||||
@@ -670,7 +921,7 @@ apiRouter.post('/funnels/:id/stages', requireRole(['admin', 'owner', 'manager',
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, color_class, order_index } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT * FROM funnel_stages WHERE id = ?', [req.params.id]);
|
||||
@@ -686,7 +937,7 @@ apiRouter.put('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 's
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/funnel_stages/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM funnel_stages WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Stage deleted.' });
|
||||
@@ -711,7 +962,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
membersQ += ' AND tenant_id = ?';
|
||||
membersParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -729,7 +980,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
teamsQ += ' AND tenant_id = ?';
|
||||
teamsParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -747,12 +998,12 @@ apiRouter.get('/search', async (req, res) => {
|
||||
}
|
||||
|
||||
// 4. Search Attendances
|
||||
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?';
|
||||
let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?';
|
||||
const attendancesParams = [queryStr];
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
// No extra filters
|
||||
} else if (req.user.role === 'admin' || req.user.role === 'owner') {
|
||||
} else if (req.user.role === 'admin') {
|
||||
attendancesQ += ' AND a.tenant_id = ?';
|
||||
attendancesParams.push(req.user.tenant_id);
|
||||
} else if (req.user.role === 'manager') {
|
||||
@@ -842,6 +1093,189 @@ apiRouter.get('/attendances/:id', async (req, res) => {
|
||||
} catch (error) { res.status(500).json({ error: error.message }); }
|
||||
});
|
||||
|
||||
// --- API Key Management Routes ---
|
||||
apiRouter.get('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const { tenantId } = req.query;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
if (!effectiveTenantId || effectiveTenantId === 'all') return res.json([]);
|
||||
|
||||
const [rows] = await pool.query('SELECT id, name, created_at, last_used_at, CONCAT(SUBSTRING(secret_key, 1, 14), "...") as masked_key FROM api_keys WHERE tenant_id = ?', [effectiveTenantId]);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/api-keys', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
const id = `apk_${crypto.randomUUID().split('-')[0]}`;
|
||||
// Generate a strong, random 32-byte hex string for the secret key
|
||||
const secretKey = `fasto_sk_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO api_keys (id, tenant_id, name, secret_key) VALUES (?, ?, ?, ?)',
|
||||
[id, effectiveTenantId, name || 'Nova Integração API', secretKey]
|
||||
);
|
||||
|
||||
// We only return the actual secret key ONCE during creation.
|
||||
res.status(201).json({ id, secret_key: secretKey, message: 'Chave criada. Salve-a agora, ela não será exibida novamente.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/api-keys/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM api_keys WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Chave não encontrada' });
|
||||
if (req.user.role !== 'super_admin' && existing[0].tenant_id !== req.user.tenant_id) return res.status(403).json({ error: 'Acesso negado' });
|
||||
|
||||
await pool.query('DELETE FROM api_keys WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Chave de API revogada com sucesso.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- External Integration API (n8n) ---
|
||||
apiRouter.get('/integration/users', requireRole(['admin']), async (req, res) => {
|
||||
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT u.id, u.name, u.email, t.name as team_name FROM users u LEFT JOIN teams t ON u.team_id = t.id WHERE u.tenant_id = ? AND u.status = "active"',
|
||||
[req.user.tenant_id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.get('/integration/origins', requireRole(['admin']), async (req, res) => {
|
||||
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
try {
|
||||
const [groups] = await pool.query('SELECT id, name FROM origin_groups WHERE tenant_id = ?', [req.user.tenant_id]);
|
||||
if (groups.length === 0) return res.json([]);
|
||||
|
||||
const [items] = await pool.query('SELECT origin_group_id, name FROM origin_items WHERE origin_group_id IN (?) ORDER BY created_at ASC', [groups.map(g => g.id)]);
|
||||
const [teams] = await pool.query('SELECT id as team_id, name as team_name, origin_group_id FROM teams WHERE tenant_id = ? AND origin_group_id IS NOT NULL', [req.user.tenant_id]);
|
||||
|
||||
const result = groups.map(g => ({
|
||||
group_name: g.name,
|
||||
origins: items.filter(i => i.origin_group_id === g.id).map(i => i.name),
|
||||
assigned_teams: teams.filter(t => t.origin_group_id === g.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.get('/integration/funnels', requireRole(['admin']), async (req, res) => {
|
||||
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
try {
|
||||
const [funnels] = await pool.query('SELECT id, name FROM funnels WHERE tenant_id = ?', [req.user.tenant_id]);
|
||||
if (funnels.length === 0) return res.json([]);
|
||||
|
||||
const [stages] = await pool.query('SELECT funnel_id, name, order_index FROM funnel_stages WHERE funnel_id IN (?) ORDER BY order_index ASC', [funnels.map(f => f.id)]);
|
||||
const [teams] = await pool.query('SELECT id as team_id, name as team_name, funnel_id FROM teams WHERE tenant_id = ? AND funnel_id IS NOT NULL', [req.user.tenant_id]);
|
||||
|
||||
const result = funnels.map(f => ({
|
||||
funnel_name: f.name,
|
||||
stages: stages.filter(s => s.funnel_id === f.id).map(s => s.name),
|
||||
assigned_teams: teams.filter(t => t.funnel_id === f.id).map(t => ({ id: t.team_id, name: t.team_name }))
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, res) => {
|
||||
if (!req.user.is_api_key) return res.status(403).json({ error: 'Endpoint restrito a chaves de API.' });
|
||||
|
||||
const {
|
||||
user_id,
|
||||
origin,
|
||||
funnel_stage,
|
||||
title,
|
||||
full_summary,
|
||||
score,
|
||||
first_response_time_min,
|
||||
handling_time_min,
|
||||
product_requested,
|
||||
product_sold,
|
||||
converted,
|
||||
attention_points,
|
||||
improvement_points
|
||||
} = req.body;
|
||||
|
||||
if (!user_id || !origin || !funnel_stage || !title) {
|
||||
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate user belongs to the API Key's tenant
|
||||
const [users] = await pool.query('SELECT id FROM users WHERE id = ? AND tenant_id = ? AND status = "active"', [user_id, req.user.tenant_id]);
|
||||
if (users.length === 0) return res.status(400).json({ error: 'user_id inválido, inativo ou não pertence a esta organização.' });
|
||||
|
||||
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
`INSERT INTO attendances (
|
||||
id, tenant_id, user_id, title, full_summary, score,
|
||||
first_response_time_min, handling_time_min,
|
||||
funnel_stage, origin, product_requested, product_sold,
|
||||
converted, attention_points, improvement_points
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attId,
|
||||
req.user.tenant_id,
|
||||
user_id,
|
||||
title,
|
||||
full_summary || null,
|
||||
score || 0,
|
||||
first_response_time_min || 0,
|
||||
handling_time_min || 0,
|
||||
funnel_stage,
|
||||
origin,
|
||||
product_requested || null,
|
||||
product_sold || null,
|
||||
converted ? 1 : 0,
|
||||
attention_points ? JSON.stringify(attention_points) : null,
|
||||
improvement_points ? JSON.stringify(improvement_points) : null
|
||||
]
|
||||
);
|
||||
|
||||
// Automation Trigger: "Venda Fechada!" (Ganhos)
|
||||
if (converted) {
|
||||
// Find the user's manager/admin
|
||||
const [managers] = await pool.query(
|
||||
"SELECT id FROM users WHERE tenant_id = ? AND role IN ('admin', 'manager') AND id != ?",
|
||||
[req.user.tenant_id, user_id]
|
||||
);
|
||||
const [agentInfo] = await pool.query("SELECT name FROM users WHERE id = ?", [user_id]);
|
||||
const agentName = agentInfo[0]?.name || 'Um agente';
|
||||
|
||||
for (const m of managers) {
|
||||
await pool.query(
|
||||
'INSERT INTO notifications (id, user_id, type, title, message, link) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[crypto.randomUUID(), m.id, 'success', 'Venda Fechada!', `${agentName} converteu um lead em ${funnel_stage}.`, `/attendances/${attId}`]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ id: attId, message: 'Atendimento registrado com sucesso.' });
|
||||
} catch (error) {
|
||||
console.error('Integration Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Tenant Routes ---
|
||||
apiRouter.get('/tenants', requireRole(['super_admin']), async (req, res) => {
|
||||
try {
|
||||
@@ -882,7 +1316,7 @@ apiRouter.get('/teams', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.post('/teams', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, description, tenantId } = req.body;
|
||||
const effectiveTenantId = req.user.role === 'super_admin' ? tenantId : req.user.tenant_id;
|
||||
try {
|
||||
@@ -898,7 +1332,7 @@ apiRouter.post('/teams', requireRole(['admin', 'owner', 'super_admin']), async (
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.put('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
|
||||
@@ -918,7 +1352,7 @@ apiRouter.put('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), asyn
|
||||
}
|
||||
});
|
||||
|
||||
apiRouter.delete('/teams/:id', requireRole(['admin', 'owner', 'super_admin']), async (req, res) => {
|
||||
apiRouter.delete('/teams/:id', requireRole(['admin', 'super_admin']), async (req, res) => {
|
||||
try {
|
||||
const [existing] = await pool.query('SELECT tenant_id FROM teams WHERE id = ?', [req.params.id]);
|
||||
if (existing.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -1098,9 +1532,9 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (sound_enabled):', err.message);
|
||||
}
|
||||
|
||||
// Update origin enum
|
||||
// Update origin to VARCHAR for custom origins
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin ENUM('WhatsApp','Instagram','Website','LinkedIn','Indicação') NOT NULL");
|
||||
await connection.query("ALTER TABLE attendances MODIFY COLUMN origin VARCHAR(255) NOT NULL");
|
||||
} catch (err) {
|
||||
console.log('Schema update note (origin):', err.message);
|
||||
}
|
||||
@@ -1112,6 +1546,66 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
||||
console.log('Schema update note (funnel_stage):', err.message);
|
||||
}
|
||||
|
||||
// Add full_summary column for detailed AI analysis
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances ADD COLUMN full_summary TEXT DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (full_summary):', err.message);
|
||||
}
|
||||
|
||||
// Create origin_groups table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS origin_groups (
|
||||
id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY tenant_id (tenant_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Create origin_items table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS origin_items (
|
||||
id varchar(36) NOT NULL,
|
||||
origin_group_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY origin_group_id (origin_group_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Attempt to add color_class if table already existed without it
|
||||
try {
|
||||
await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message);
|
||||
}
|
||||
|
||||
// Add origin_group_id to teams
|
||||
try {
|
||||
await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (teams.origin_group_id):', err.message);
|
||||
}
|
||||
|
||||
// Rename summary to title
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances RENAME COLUMN summary TO title");
|
||||
} catch (err) {
|
||||
if (err.code !== 'ER_BAD_FIELD_ERROR' && err.code !== 'ER_DUP_FIELDNAME') {
|
||||
// If RENAME COLUMN fails (older mysql), try CHANGE
|
||||
try {
|
||||
await connection.query("ALTER TABLE attendances CHANGE COLUMN summary title TEXT");
|
||||
} catch (e) {
|
||||
console.log('Schema update note (summary to title):', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create funnels table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS funnels (
|
||||
@@ -1138,6 +1632,35 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Create api_keys table for external integrations (n8n)
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id varchar(36) NOT NULL,
|
||||
tenant_id varchar(36) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
secret_key varchar(255) NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY secret_key (secret_key),
|
||||
KEY tenant_id (tenant_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Create refresh_tokens table for persistent sessions
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id varchar(36) NOT NULL,
|
||||
user_id varchar(36) NOT NULL,
|
||||
token varchar(255) NOT NULL,
|
||||
expires_at timestamp NOT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY token (token),
|
||||
KEY user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// Add funnel_id to teams
|
||||
try {
|
||||
await connection.query("ALTER TABLE teams ADD COLUMN funnel_id VARCHAR(36) DEFAULT NULL");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
||||
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
|
||||
ChevronLeft, ChevronRight
|
||||
ChevronLeft, ChevronRight, Key, Target
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||
@@ -16,6 +16,7 @@ import notificationSound from '../src/assets/audio/notification.mp3';
|
||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
|
||||
isActive
|
||||
@@ -238,7 +239,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
<>
|
||||
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
|
||||
{currentUser.role !== 'manager' && (
|
||||
<>
|
||||
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
|
||||
<SidebarItem to="/admin/origins" icon={Target} label="Origens de Lead" collapsed={isSidebarCollapsed} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -249,14 +255,14 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
<>
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="pt-2 pb-2 px-4 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest whitespace-nowrap">
|
||||
Super Admin
|
||||
Super Admin
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
)} </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">
|
||||
@@ -442,10 +448,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
KPI
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.summary}</div>
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
|
||||
<div className="text-[10px] text-zinc-500 dark:text-dark-muted flex justify-between mt-0.5">
|
||||
<span className="font-medium">{a.user_name}</span>
|
||||
<span>{new Date(a.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(a.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -540,7 +546,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
||||
{new Date(n.created_at).toLocaleDateString()}
|
||||
{new Date(n.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
||||
|
||||
161
constants.ts
161
constants.ts
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
198
pages/ApiKeys.tsx
Normal file
198
pages/ApiKeys.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -98,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">
|
||||
@@ -136,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 */}
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
import {
|
||||
Users, Clock, Phone, TrendingUp, Filter
|
||||
} from 'lucide-react';
|
||||
import { getAttendances, getUsers, getTeams, getUserById, getFunnels } from '../services/dataService';
|
||||
import { getAttendances, getUsers, getTeams, getUserById, getFunnels, getOrigins } from '../services/dataService';
|
||||
import { COLORS } from '../constants';
|
||||
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef } 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';
|
||||
@@ -29,6 +29,7 @@ export const Dashboard: React.FC = () => {
|
||||
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>({
|
||||
@@ -57,12 +58,13 @@ export const Dashboard: React.FC = () => {
|
||||
const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } };
|
||||
|
||||
// Fetch users, attendances, teams, funnels and current user in parallel
|
||||
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, me] = await Promise.all([
|
||||
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, fetchedOrigins, me] = await Promise.all([
|
||||
getUsers(tenantId),
|
||||
getAttendances(tenantId, filters),
|
||||
getAttendances(tenantId, prevFilters),
|
||||
getTeams(tenantId),
|
||||
getFunnels(tenantId),
|
||||
getOrigins(tenantId),
|
||||
storedUserId ? getUserById(storedUserId) : null
|
||||
]);
|
||||
|
||||
@@ -70,6 +72,7 @@ export const Dashboard: React.FC = () => {
|
||||
setData(fetchedData);
|
||||
setPrevData(prevFetchedData);
|
||||
setTeams(fetchedTeams);
|
||||
setOriginDefs(fetchedOrigins);
|
||||
if (me) setCurrentUser(me);
|
||||
|
||||
// Determine which funnel to display
|
||||
@@ -82,6 +85,14 @@ export const Dashboard: React.FC = () => {
|
||||
|
||||
setFunnelDefs(activeFunnel && activeFunnel.stages ? activeFunnel.stages.sort((a: any, b: any) => a.order_index - b.order_index) : []);
|
||||
|
||||
// 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 {
|
||||
@@ -168,18 +179,59 @@ export const Dashboard: React.FC = () => {
|
||||
}));
|
||||
}, [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(() => {
|
||||
@@ -305,13 +357,12 @@ export const Dashboard: React.FC = () => {
|
||||
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>
|
||||
|
||||
@@ -419,10 +470,9 @@ export const Dashboard: React.FC = () => {
|
||||
{originData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS.origins[entry.name as keyof typeof COLORS.origins] || COLORS.charts[index % COLORS.charts.length]}
|
||||
fill={entry.hexColor || COLORS.charts[index % COLORS.charts.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
))} </Pie>
|
||||
<Tooltip
|
||||
formatter={(value: any) => [value, 'Leads']}
|
||||
contentStyle={{
|
||||
|
||||
@@ -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
332
pages/Origins.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { getAttendances, getUserById, getFunnels } from '../services/dataService';
|
||||
import { Attendance, User, FunnelStage, DashboardFilter, FunnelStageDef } 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';
|
||||
|
||||
@@ -12,6 +12,7 @@ export const UserDetail: React.FC = () => {
|
||||
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>({
|
||||
@@ -32,12 +33,13 @@ export const UserDetail: React.FC = () => {
|
||||
setUser(u);
|
||||
|
||||
if (u && tenantId) {
|
||||
const [data, fetchedFunnels] = await Promise.all([
|
||||
const [data, fetchedFunnels, fetchedOrigins] = await Promise.all([
|
||||
getAttendances(tenantId, {
|
||||
...filters,
|
||||
userId: id
|
||||
}),
|
||||
getFunnels(tenantId)
|
||||
getFunnels(tenantId),
|
||||
getOrigins(tenantId)
|
||||
]);
|
||||
setAttendances(data);
|
||||
|
||||
@@ -48,6 +50,13 @@ export const UserDetail: React.FC = () => {
|
||||
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);
|
||||
@@ -160,13 +169,12 @@ export const UserDetail: React.FC = () => {
|
||||
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">
|
||||
@@ -214,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">
|
||||
|
||||
@@ -19,6 +19,7 @@ export const UserProfile: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchUserAndTenant = async () => {
|
||||
const storedUserId = localStorage.getItem('ctms_user_id');
|
||||
|
||||
if (storedUserId) {
|
||||
try {
|
||||
const fetchedUser = await getUserById(storedUserId);
|
||||
@@ -201,9 +202,17 @@ export const UserProfile: React.FC = () => {
|
||||
id="fullName"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400 transition-all sm:text-sm"
|
||||
disabled={user.role === 'agent'}
|
||||
className={`block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-800 rounded-lg sm:text-sm transition-all ${
|
||||
user.role === 'agent'
|
||||
? 'bg-zinc-50 dark:bg-zinc-900/50 text-zinc-500 dark:text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:ring-2 focus:ring-yellow-400/20 focus:border-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{user.role === 'agent' && (
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">Contate um administrador para alterar seu nome.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Attendance, DashboardFilter, User } from '../types';
|
||||
// Em desenvolvimento, aponta para o localhost:3001
|
||||
const API_URL = import.meta.env.PROD ? '/api' : 'http://localhost:3001/api';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('ctms_token');
|
||||
const getHeaders = (customToken?: string) => {
|
||||
const token = customToken || localStorage.getItem('ctms_token');
|
||||
// Evitar enviar "undefined" ou "null" como strings se o localStorage estiver corrompido
|
||||
if (!token || token === 'undefined' || token === 'null') return { 'Content-Type': 'application/json' };
|
||||
|
||||
@@ -17,9 +17,76 @@ const getHeaders = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Global flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: ((token: string) => void)[] = [];
|
||||
|
||||
const onRefreshed = (token: string) => {
|
||||
refreshSubscribers.forEach(cb => cb(token));
|
||||
refreshSubscribers = [];
|
||||
};
|
||||
|
||||
const addRefreshSubscriber = (cb: (token: string) => void) => {
|
||||
refreshSubscribers.push(cb);
|
||||
};
|
||||
|
||||
export const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
let response = await fetch(url, options);
|
||||
|
||||
// If unauthorized, attempt to refresh the token
|
||||
if (response.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
|
||||
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
logout();
|
||||
return response;
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If a refresh is already in progress, wait for it to finish and retry
|
||||
return new Promise(resolve => {
|
||||
addRefreshSubscriber((newToken) => {
|
||||
options.headers = getHeaders(newToken);
|
||||
resolve(fetch(url, options));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshResponse = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
});
|
||||
|
||||
if (!refreshResponse.ok) {
|
||||
throw new Error('Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = await refreshResponse.json();
|
||||
localStorage.setItem('ctms_token', data.token);
|
||||
|
||||
// Retry the original request
|
||||
options.headers = getHeaders(data.token);
|
||||
response = await fetch(url, options);
|
||||
|
||||
onRefreshed(data.token);
|
||||
} catch (err) {
|
||||
console.error("Session expired or refresh failed:", err);
|
||||
logout();
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const getNotifications = async (): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch notifications');
|
||||
@@ -32,7 +99,7 @@ export const getNotifications = async (): Promise<any[]> => {
|
||||
|
||||
export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -45,7 +112,7 @@ export const markNotificationAsRead = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/read-all`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/read-all`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -58,7 +125,7 @@ export const markAllNotificationsAsRead = async (): Promise<boolean> => {
|
||||
|
||||
export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -71,7 +138,7 @@ export const deleteNotification = async (id: string): Promise<boolean> => {
|
||||
|
||||
export const clearAllNotifications = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/notifications/clear-all`, {
|
||||
const response = await apiFetch(`${API_URL}/notifications/clear-all`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -85,7 +152,7 @@ export const clearAllNotifications = async (): Promise<boolean> => {
|
||||
// --- Funnels Functions ---
|
||||
export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar funis');
|
||||
@@ -97,7 +164,7 @@ export const getFunnels = async (tenantId: string): Promise<any[]> => {
|
||||
};
|
||||
|
||||
export const createFunnel = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/funnels`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -111,7 +178,7 @@ export const createFunnel = async (data: { name: string, tenantId: string }): Pr
|
||||
|
||||
export const updateFunnel = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -125,7 +192,7 @@ export const updateFunnel = async (id: string, data: { name?: string, teamIds?:
|
||||
|
||||
export const deleteFunnel = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnels/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -137,7 +204,7 @@ export const deleteFunnel = async (id: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const createFunnelStage = async (funnelId: string, data: any): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
||||
const response = await apiFetch(`${API_URL}/funnels/${funnelId}/stages`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -151,7 +218,7 @@ export const createFunnelStage = async (funnelId: string, data: any): Promise<an
|
||||
|
||||
export const updateFunnelStage = async (id: string, data: any): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
@@ -165,7 +232,7 @@ export const updateFunnelStage = async (id: string, data: any): Promise<boolean>
|
||||
|
||||
export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/funnel_stages/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -176,9 +243,143 @@ export const deleteFunnelStage = async (id: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Origins Functions ---
|
||||
export const getOrigins = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/origins?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar origens');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getOrigins):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createOriginGroup = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await apiFetch(`${API_URL}/origins`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erro ao criar grupo de origens');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateOriginGroup = async (id: string, data: { name?: string, teamIds?: string[] }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (updateOriginGroup):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteOriginGroup = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/origins/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (deleteOriginGroup):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
|
||||
const response = await apiFetch(`${API_URL}/origins/${groupId}/items`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erro ao criar item de origem');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (updateOriginItem):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteOriginItem = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/origin_items/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (deleteOriginItem):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- API Keys Functions ---
|
||||
export const getApiKeys = async (tenantId: string): Promise<any[]> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api-keys?tenantId=${tenantId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Falha ao buscar chaves');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("API Error (getApiKeys):", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createApiKey = async (data: { name: string, tenantId: string }): Promise<any> => {
|
||||
const response = await apiFetch(`${API_URL}/api-keys`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erro ao criar chave de API');
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_URL}/api-keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("API Error (deleteApiKey):", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchGlobal = async (query: string): Promise<{ members: User[], teams: any[], attendances: any[], organizations?: any[] }> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||
const response = await apiFetch(`${API_URL}/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!response.ok) throw new Error('Search failed');
|
||||
@@ -202,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()
|
||||
});
|
||||
|
||||
@@ -223,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');
|
||||
@@ -237,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)
|
||||
@@ -279,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}` } : {})
|
||||
@@ -298,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)
|
||||
@@ -318,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()
|
||||
});
|
||||
@@ -331,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;
|
||||
@@ -349,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');
|
||||
@@ -367,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');
|
||||
@@ -380,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)
|
||||
@@ -394,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)
|
||||
@@ -408,7 +613,7 @@ export const updateTeam = async (id: string, teamData: any): Promise<boolean> =>
|
||||
|
||||
export const deleteTeam = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/teams/${id}`, {
|
||||
const response = await apiFetch(`${API_URL}/teams/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -421,7 +626,7 @@ export const deleteTeam = async (id: string): Promise<boolean> => {
|
||||
|
||||
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)
|
||||
@@ -437,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)
|
||||
@@ -451,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()
|
||||
});
|
||||
@@ -469,9 +674,23 @@ export let isReloadingForImpersonation = false;
|
||||
|
||||
export const logout = () => {
|
||||
if (isReloadingForImpersonation) return; // Prevent logout if we are just switching tokens
|
||||
|
||||
const refreshToken = localStorage.getItem('ctms_refresh_token');
|
||||
|
||||
// Clear local storage synchronously for instant UI update
|
||||
localStorage.removeItem('ctms_token');
|
||||
localStorage.removeItem('ctms_refresh_token');
|
||||
localStorage.removeItem('ctms_user_id');
|
||||
localStorage.removeItem('ctms_tenant_id');
|
||||
|
||||
// Attempt to revoke in background
|
||||
if (refreshToken) {
|
||||
fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken })
|
||||
}).catch(e => console.error("Failed to revoke refresh token", e));
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (credentials: any): Promise<any> => {
|
||||
@@ -492,6 +711,7 @@ export const login = async (credentials: any): Promise<any> => {
|
||||
const data = isJson ? await response.json() : null;
|
||||
if (data && data.token) {
|
||||
localStorage.setItem('ctms_token', data.token);
|
||||
if (data.refreshToken) localStorage.setItem('ctms_refresh_token', data.refreshToken);
|
||||
localStorage.setItem('ctms_user_id', data.user.id);
|
||||
localStorage.setItem('ctms_tenant_id', data.user.tenant_id || '');
|
||||
}
|
||||
@@ -499,7 +719,7 @@ export const login = async (credentials: any): Promise<any> => {
|
||||
};
|
||||
|
||||
export const impersonateTenant = async (tenantId: string): Promise<any> => {
|
||||
const response = await fetch(`${API_URL}/impersonate/${tenantId}`, {
|
||||
const response = await apiFetch(`${API_URL}/impersonate/${tenantId}`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
@@ -567,7 +787,7 @@ export const returnToSuperAdmin = (): boolean => {
|
||||
};
|
||||
|
||||
export const register = async (userData: any): Promise<boolean> => {
|
||||
const response = await fetch(`${API_URL}/auth/register`, {
|
||||
const response = await apiFetch(`${API_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData)
|
||||
@@ -580,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)
|
||||
@@ -593,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 })
|
||||
@@ -612,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 })
|
||||
|
||||
22
types.ts
22
types.ts
@@ -23,6 +23,21 @@ export interface FunnelDef {
|
||||
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;
|
||||
@@ -42,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;
|
||||
|
||||
Reference in New Issue
Block a user