feat: comprehensive dashboard refactor and performance stabilization
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m33s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 3m33s
This commit is contained in:
@@ -993,7 +993,7 @@ apiRouter.get('/search', async (req, res) => {
|
|||||||
|
|
||||||
// 3. Search Organizations (only for super_admin)
|
// 3. Search Organizations (only for super_admin)
|
||||||
if (req.user.role === 'super_admin') {
|
if (req.user.role === 'super_admin') {
|
||||||
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ?', [queryStr, queryStr]);
|
const [orgs] = await pool.query('SELECT id, name, slug, status FROM tenants WHERE name LIKE ? OR slug LIKE ? LIMIT 5', [queryStr, queryStr]);
|
||||||
results.organizations = orgs;
|
results.organizations = orgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,6 +1013,7 @@ apiRouter.get('/search', async (req, res) => {
|
|||||||
attendancesQ += ' AND a.user_id = ?';
|
attendancesQ += ' AND a.user_id = ?';
|
||||||
attendancesParams.push(req.user.id);
|
attendancesParams.push(req.user.id);
|
||||||
}
|
}
|
||||||
|
attendancesQ += ' LIMIT 10';
|
||||||
const [attendances] = await pool.query(attendancesQ, attendancesParams);
|
const [attendances] = await pool.query(attendancesQ, attendancesParams);
|
||||||
results.attendances = attendances;
|
results.attendances = attendances;
|
||||||
|
|
||||||
@@ -1047,17 +1048,16 @@ apiRouter.get('/attendances', async (req, res) => {
|
|||||||
params.push(teamId);
|
params.push(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userId && userId !== 'all') {
|
if (userId && userId !== 'all') {
|
||||||
// check if it's a slug or id
|
// check if it's a slug or id
|
||||||
if (userId.startsWith('u_')) {
|
if (userId.startsWith('u_') || userId.length === 36) {
|
||||||
q += ' AND a.user_id = ?';
|
q += ' AND a.user_id = ?';
|
||||||
params.push(userId);
|
params.push(userId);
|
||||||
} else {
|
} else {
|
||||||
q += ' AND u.slug = ?';
|
q += ' AND u.slug = ?';
|
||||||
params.push(userId);
|
params.push(userId);
|
||||||
}
|
}
|
||||||
}
|
} }
|
||||||
}
|
|
||||||
|
|
||||||
if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); }
|
if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); }
|
||||||
if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); }
|
if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); }
|
||||||
|
|||||||
184
backend/seed_data.js
Normal file
184
backend/seed_data.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
const pool = require('./db.js');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
"Pneu Aro 13 175/70R13",
|
||||||
|
"Pneu Aro 14 175/70R14",
|
||||||
|
"Pneu Aro 15 195/60R15",
|
||||||
|
"Pneu Aro 16 205/55R16",
|
||||||
|
"Pneu Aro 17 225/45R17",
|
||||||
|
"Alinhamento e Balanceamento",
|
||||||
|
"Revisão do Sistema de Arrefecimento",
|
||||||
|
"Manutenção de Freios",
|
||||||
|
"Troca de Óleo e Filtros",
|
||||||
|
"Limpeza de Bico"
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_ORIGINS = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'];
|
||||||
|
const DEFAULT_FUNNELS = ['Sem atendimento', 'Identificação', 'Negociação', 'Ganhos', 'Perdidos'];
|
||||||
|
|
||||||
|
const FIRST_NAMES = ["Ana", "Bruno", "Carlos", "Daniela", "Eduardo", "Fernanda", "Gabriel", "Helena", "Igor", "Julia", "Lucas", "Mariana", "Nicolas", "Olivia", "Pedro", "Quintino", "Rafael", "Sofia", "Thiago", "Ursula", "Victor", "Wagner", "Xuxa", "Yuri", "Zeca", "Amanda", "Beto", "Camila", "Diogo", "Elisa", "Fabio", "Gisele", "Henrique", "Isabela", "Joao", "Karla"];
|
||||||
|
const LAST_NAMES = ["Silva", "Santos", "Oliveira", "Souza", "Rodrigues", "Ferreira", "Alves", "Pereira", "Lima", "Gomes", "Costa", "Ribeiro", "Martins", "Carvalho", "Almeida", "Lopes", "Soares", "Fernandes", "Vieira", "Barbosa"];
|
||||||
|
|
||||||
|
function getRandomItem(arr) {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomInt(min, max) {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniqueNames(count) {
|
||||||
|
const names = new Set();
|
||||||
|
while (names.size < count) {
|
||||||
|
names.add(`${getRandomItem(FIRST_NAMES)} ${getRandomItem(LAST_NAMES)}`);
|
||||||
|
}
|
||||||
|
return Array.from(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Iniciando geração de dados...');
|
||||||
|
|
||||||
|
// 1. Encontrar o tenant "teste"
|
||||||
|
const [tenants] = await pool.query(`SELECT id FROM tenants WHERE slug = 'teste' OR name LIKE '%teste%' LIMIT 1`);
|
||||||
|
if (tenants.length === 0) {
|
||||||
|
console.log('❌ Tenant "teste" não encontrado.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const tenantId = tenants[0].id;
|
||||||
|
console.log(`✅ Tenant "teste" encontrado: ${tenantId}`);
|
||||||
|
|
||||||
|
// Pegar origens e funis dinâmicos se existirem
|
||||||
|
let origins = [...DEFAULT_ORIGINS];
|
||||||
|
let funnels = [...DEFAULT_FUNNELS];
|
||||||
|
let originGroupId = null;
|
||||||
|
let funnelId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [originGroups] = await pool.query(`SELECT id FROM origin_groups WHERE tenant_id = ? LIMIT 1`, [tenantId]);
|
||||||
|
if (originGroups.length > 0) {
|
||||||
|
originGroupId = originGroups[0].id;
|
||||||
|
const [originItems] = await pool.query(`SELECT name FROM origin_items WHERE origin_group_id = ?`, [originGroupId]);
|
||||||
|
if (originItems.length > 0) origins = originItems.map(o => o.name);
|
||||||
|
}
|
||||||
|
const [funnelGroups] = await pool.query(`SELECT id FROM funnels WHERE tenant_id = ? LIMIT 1`, [tenantId]);
|
||||||
|
if (funnelGroups.length > 0) {
|
||||||
|
funnelId = funnelGroups[0].id;
|
||||||
|
const [funnelStages] = await pool.query(`SELECT name FROM funnel_stages WHERE funnel_id = ?`, [funnelId]);
|
||||||
|
if (funnelStages.length > 0) funnels = funnelStages.map(f => f.name);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Aviso: Usando origens/funis padrão devido a erro na busca de dinâmicos.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Limpar dados existentes do tenant (exceto admin e tenant em si)
|
||||||
|
console.log('🧹 Limpando attendances antigas...');
|
||||||
|
await pool.query(`DELETE FROM attendances WHERE tenant_id = ?`, [tenantId]);
|
||||||
|
|
||||||
|
console.log('🧹 Limpando usuários antigos (exceto admin)...');
|
||||||
|
await pool.query(`DELETE FROM users WHERE tenant_id = ? AND role != 'admin'`, [tenantId]);
|
||||||
|
|
||||||
|
console.log('🧹 Limpando times antigos...');
|
||||||
|
await pool.query(`DELETE FROM teams WHERE tenant_id = ?`, [tenantId]);
|
||||||
|
|
||||||
|
// 3. Criar 5 times
|
||||||
|
const teams = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const teamId = crypto.randomUUID();
|
||||||
|
await pool.query(`INSERT INTO teams (id, tenant_id, name, description, origin_group_id, funnel_id) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[teamId, tenantId, `Equipe Vendas ${i}`, `Equipe responsável pela região ${i}`, originGroupId, funnelId]);
|
||||||
|
teams.push(teamId);
|
||||||
|
}
|
||||||
|
console.log('✅ 5 times criados.');
|
||||||
|
|
||||||
|
// 4. Criar 36 usuários (1 manager por time = 5 managers, 31 agents)
|
||||||
|
const users = [];
|
||||||
|
const roles = ['manager', 'manager', 'manager', 'manager', 'manager', ...Array(31).fill('agent')];
|
||||||
|
const uniqueNames = generateUniqueNames(36);
|
||||||
|
|
||||||
|
// Distribuir usuários entre os times
|
||||||
|
for (let i = 0; i < 36; i++) {
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const role = roles[i];
|
||||||
|
const teamId = teams[i % 5];
|
||||||
|
const name = uniqueNames[i];
|
||||||
|
const email = `${name.split(' ')[0].toLowerCase()}.${name.split(' ')[1].toLowerCase()}.${i}@teste.com`;
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (id, tenant_id, team_id, name, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, 'active')`,
|
||||||
|
[userId, tenantId, teamId, name, email, 'dummy_hash_not_for_login', role]
|
||||||
|
);
|
||||||
|
users.push(userId);
|
||||||
|
}
|
||||||
|
console.log('✅ 36 usuários criados (5 managers, 31 agents).');
|
||||||
|
|
||||||
|
// 5. Gerar attendances (01/01/2026 até 22/04/2026)
|
||||||
|
// 112 dias totais
|
||||||
|
const startDate = new Date('2026-01-01T08:00:00Z');
|
||||||
|
const endDate = new Date('2026-04-22T18:00:00Z');
|
||||||
|
|
||||||
|
let currentDay = new Date(startDate);
|
||||||
|
const attendancesToInsert = [];
|
||||||
|
|
||||||
|
console.log('⏳ Gerando dados de attendances em memória...');
|
||||||
|
while (currentDay <= endDate) {
|
||||||
|
// Pular domingos para dar mais realismo, ou deixar todos os dias? Vamos deixar todos os dias.
|
||||||
|
for (const userId of users) {
|
||||||
|
const numAttendances = getRandomInt(3, 5); // 3 a 5 por dia por usuário
|
||||||
|
for (let a = 0; a < numAttendances; a++) {
|
||||||
|
const createdAt = new Date(currentDay);
|
||||||
|
createdAt.setHours(getRandomInt(8, 17), getRandomInt(0, 59), getRandomInt(0, 59));
|
||||||
|
|
||||||
|
const isConverted = Math.random() > 0.7; // 30% conversão
|
||||||
|
const reqProduct = getRandomItem(PRODUCTS);
|
||||||
|
const soldProduct = isConverted ? reqProduct : null;
|
||||||
|
|
||||||
|
let score = getRandomInt(40, 100);
|
||||||
|
if (isConverted) score = getRandomInt(85, 100);
|
||||||
|
|
||||||
|
attendancesToInsert.push([
|
||||||
|
crypto.randomUUID(),
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
isConverted ? "Venda efetuada com sucesso" : "Cliente não finalizou a compra",
|
||||||
|
isConverted ? "Cliente comprou com sucesso. Excelente atendimento." : "Cliente achou o valor alto e desistiu.",
|
||||||
|
score,
|
||||||
|
getRandomInt(1, 45), // first_response_time_min
|
||||||
|
getRandomInt(10, 120), // handling_time_min
|
||||||
|
isConverted ? 'Ganhos' : (Math.random() > 0.5 ? 'Perdidos' : getRandomItem(funnels)), // funnel_stage
|
||||||
|
getRandomItem(origins), // origin
|
||||||
|
reqProduct,
|
||||||
|
soldProduct,
|
||||||
|
isConverted ? 1 : 0,
|
||||||
|
JSON.stringify(isConverted ? [] : ["Faltou oferecer desconto", "Demora no primeiro contato"]),
|
||||||
|
JSON.stringify(["Melhorar rapport inicial"]),
|
||||||
|
createdAt
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentDay.setDate(currentDay.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Inserir em lotes
|
||||||
|
console.log(`⏳ Inserindo ${attendancesToInsert.length} attendances no banco em lotes...`);
|
||||||
|
const batchSize = 1000;
|
||||||
|
for (let i = 0; i < attendancesToInsert.length; i += batchSize) {
|
||||||
|
const batch = attendancesToInsert.slice(i, i + batchSize);
|
||||||
|
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, created_at)
|
||||||
|
VALUES ?`,
|
||||||
|
[batch]
|
||||||
|
);
|
||||||
|
process.stdout.write(`\r✅ Inseridos ${Math.min(i + batchSize, attendancesToInsert.length)} / ${attendancesToInsert.length}`);
|
||||||
|
}
|
||||||
|
console.log('\n🎉 Todos os dados foram gerados com sucesso!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erro:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
27
debug.txt
27
debug.txt
@@ -1,27 +0,0 @@
|
|||||||
Look at `playNotificationSound`:
|
|
||||||
```javascript
|
|
||||||
const playNotificationSound = () => {
|
|
||||||
if (currentUser?.sound_enabled !== false && audioRef.current) {
|
|
||||||
// Reset time to 0 to allow rapid replays
|
|
||||||
audioRef.current.currentTime = 0;
|
|
||||||
const playPromise = audioRef.current.play();
|
|
||||||
```
|
|
||||||
Is `currentUser` loaded when `loadNotifications` fires for the first time after `isInitialLoadRef` is false?
|
|
||||||
Yes, `useEffect` calls `fetchCurrentUser()`, which sets `currentUser`.
|
|
||||||
|
|
||||||
Wait. `setInterval` uses a closure over the state!
|
|
||||||
```javascript
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCurrentUser();
|
|
||||||
loadNotifications();
|
|
||||||
const interval = setInterval(loadNotifications, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [navigate]);
|
|
||||||
```
|
|
||||||
Oh my god. The `setInterval` callback `loadNotifications` captures the *initial* state variables, including `currentUser`, which is `null` on the first render!
|
|
||||||
If `currentUser` is `null` inside the closure, `currentUser?.sound_enabled !== false` evaluates to `true !== false` which is `true`. So that's not blocking it.
|
|
||||||
BUT `audioRef.current` might not have been rendered yet? No, `audioRef` is a ref, so it mutates in place. The closure always sees the latest `audioRef.current`.
|
|
||||||
|
|
||||||
So why does it fail or not play?
|
|
||||||
Is the browser policy blocking it silently without logging?
|
|
||||||
Let's add a robust, standalone Audio approach that doesn't rely on the DOM tag if it fails, or maybe just force a click handler to "unlock" the audio context.
|
|
||||||
36
debug2.txt
36
debug2.txt
@@ -1,36 +0,0 @@
|
|||||||
Let's see what happens during returnToSuperAdmin:
|
|
||||||
1. Decode superAdminToken
|
|
||||||
2. localStorage.setItem('ctms_token', superAdminToken)
|
|
||||||
3. localStorage.setItem('ctms_user_id', payload.id) <-- u_71657ec7
|
|
||||||
4. localStorage.setItem('ctms_tenant_id', payload.tenant_id || 'system') <-- 'system'
|
|
||||||
5. window.location.reload()
|
|
||||||
|
|
||||||
Then the app reloads.
|
|
||||||
1. AuthGuard mounts
|
|
||||||
2. const storedUserId = localStorage.getItem('ctms_user_id') <-- u_71657ec7
|
|
||||||
3. const fetchedUser = await getUserById(storedUserId)
|
|
||||||
-> GET /api/users/u_71657ec7
|
|
||||||
|
|
||||||
Backend handles GET /api/users/u_71657ec7:
|
|
||||||
req.user is derived from jwt.verify(superAdminToken)
|
|
||||||
So req.user.role === 'super_admin'
|
|
||||||
req.user.tenant_id === 'system' (or null)
|
|
||||||
|
|
||||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
|
||||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) { ... }
|
|
||||||
res.json(rows[0]);
|
|
||||||
|
|
||||||
Wait. If this is a 500 error, why does it crash?
|
|
||||||
What if `req.user` is undefined? But `authenticateToken` middleware ensures `req.user` exists.
|
|
||||||
Maybe `pool.query` fails?
|
|
||||||
No, wait. You said:
|
|
||||||
"now its sending me to login page, congrats"
|
|
||||||
Then I fixed the Base64 pad.
|
|
||||||
Then you said:
|
|
||||||
"i am still having this problem, when i go to impersionate the pages blink and i go to the tenant pages, good, but when i press to go back and stop impersionate it redirects me to the goddam login page"
|
|
||||||
Then you said:
|
|
||||||
"ok, its working but its giving some errors in dev tools and this errors is showing our users id and path ... XHRGET https://fasto.blyzer.com.br/api/users/u_71657ec7 [HTTP/2 500 14ms]"
|
|
||||||
|
|
||||||
Why 500?
|
|
||||||
Let's add a console.log in the catch block of `/users/:idOrSlug` to see the actual error.
|
|
||||||
20
debug3.txt
20
debug3.txt
@@ -1,20 +0,0 @@
|
|||||||
Wait, let's look at `index.js` line 354 BEFORE my fix:
|
|
||||||
```javascript
|
|
||||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
|
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
|
||||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
|
||||||
```
|
|
||||||
The ONLY way this throws 500 is if:
|
|
||||||
1. `pool.query` fails (e.g. database disconnected, which isn't the case).
|
|
||||||
2. `req.user` is somehow null or undefined (but `authenticateToken` guarantees it exists).
|
|
||||||
3. `rows[0]` is undefined (but `rows.length === 0` handles that).
|
|
||||||
|
|
||||||
Wait, what if `req.user.tenant_id` is null? `null !== 'tenant_xyz'` is true, so it returns 403, not 500.
|
|
||||||
|
|
||||||
What if the 500 is coming from `GET /api/users/u_71657ec7` but it's not actually hitting `/users/:idOrSlug`?
|
|
||||||
Is there a middleware or something? No.
|
|
||||||
|
|
||||||
Ah! What if the user you are impersonating was deleted from the database? `rows.length === 0` -> returns 404, not 500.
|
|
||||||
|
|
||||||
Let's check the local logs AGAIN after my recent rebuild. I added `console.error('Error in GET /users/:idOrSlug:', error);`
|
|
||||||
Let's deliberately trigger the error locally to see it. But I don't have the browser.
|
|
||||||
@@ -57,7 +57,7 @@ services:
|
|||||||
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
|
MAX_BACKUPS: 3 # Mantém apenas os 3 últimos dias
|
||||||
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
|
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/backups_db/fasto:/backup
|
- /opt/backups_db/fastogemi:/backup
|
||||||
networks:
|
networks:
|
||||||
- fasto-net
|
- fasto-net
|
||||||
|
|
||||||
|
|||||||
10
fix_db.js
10
fix_db.js
@@ -1,10 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
async function run() {
|
|
||||||
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
|
|
||||||
try {
|
|
||||||
await pool.query("ALTER TABLE attendances MODIFY COLUMN funnel_stage VARCHAR(255) NOT NULL DEFAULT 'Novo'");
|
|
||||||
console.log("Success");
|
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
pool.end();
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
@@ -46,6 +46,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ForgotPassword } from "./pages/ForgotPassword";
|
|||||||
import { ResetPassword } from "./pages/ResetPassword";
|
import { ResetPassword } from "./pages/ResetPassword";
|
||||||
import { SetupAccount } from "./pages/SetupAccount";
|
import { SetupAccount } from "./pages/SetupAccount";
|
||||||
import { UserProfile } from "./pages/UserProfile";
|
import { UserProfile } from "./pages/UserProfile";
|
||||||
|
import { Ranking } from "./pages/Ranking";
|
||||||
import { getUserById, logout } from "./services/dataService";
|
import { getUserById, logout } from "./services/dataService";
|
||||||
import { User } from "./types";
|
import { User } from "./types";
|
||||||
|
|
||||||
@@ -127,6 +128,14 @@ const App: React.FC = () => {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/ranking"
|
||||||
|
element={
|
||||||
|
<AuthGuard roles={["super_admin", "admin", "manager"]}>
|
||||||
|
<Ranking />
|
||||||
|
</AuthGuard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/teams"
|
path="/admin/teams"
|
||||||
element={
|
element={
|
||||||
@@ -3,7 +3,7 @@ import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
LayoutDashboard, Users, UserCircle, Bell, Search, Menu, X, LogOut,
|
||||||
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
|
Hexagon, Settings, Building2, Sun, Moon, Loader2, Layers,
|
||||||
ChevronLeft, ChevronRight, Key, Target
|
ChevronLeft, ChevronRight, Key, Target, Trophy, MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
deleteNotification, clearAllNotifications, returnToSuperAdmin
|
deleteNotification, clearAllNotifications, returnToSuperAdmin
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import notificationSound from '../src/assets/audio/notification.mp3';
|
import notificationSound from '../assets/audio/notification.mp3';
|
||||||
|
|
||||||
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -235,10 +235,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{!isSuperAdmin && (
|
{!isSuperAdmin && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
|
||||||
|
|
||||||
{currentUser.role !== 'agent' && (
|
{currentUser.role !== 'agent' && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/ranking" icon={Trophy} label="Ranking de Vendedores" collapsed={isSidebarCollapsed} />
|
||||||
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/admin/teams" icon={Building2} label={currentUser.role === 'manager' ? 'Meu Time' : 'Times'} collapsed={isSidebarCollapsed} />
|
||||||
|
<SidebarItem to="/admin/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
|
||||||
|
|
||||||
{currentUser.role !== 'manager' && (
|
{currentUser.role !== 'manager' && (
|
||||||
<>
|
<>
|
||||||
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
|
<SidebarItem to="/admin/funnels" icon={Layers} label="Gerenciar Funis" collapsed={isSidebarCollapsed} />
|
||||||
@@ -329,21 +332,28 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
<div className="flex items-center bg-zinc-100 dark:bg-dark-bg rounded-xl px-4 py-2.5 w-full border border-transparent focus-within:bg-white dark:focus-within:bg-dark-card focus-within:border-brand-yellow focus-within:ring-2 focus-within:ring-brand-yellow/20 dark:focus-within:ring-brand-yellow/10 transition-all">
|
<div className="flex items-center bg-zinc-100 dark:bg-dark-bg rounded-xl px-4 py-2.5 w-full border border-transparent focus-within:bg-white dark:focus-within:bg-dark-card focus-within:border-brand-yellow focus-within:ring-2 focus-within:ring-brand-yellow/20 dark:focus-within:ring-brand-yellow/10 transition-all">
|
||||||
{isSearching ? <Loader2 size={18} className="text-brand-yellow animate-spin" /> : <Search size={18} className="text-zinc-400 dark:text-dark-muted" />}
|
{isSearching ? <Loader2 size={18} className="text-brand-yellow animate-spin" /> : <Search size={18} className="text-zinc-400 dark:text-dark-muted" />}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={getSearchPlaceholder()}
|
placeholder={getSearchPlaceholder()}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onFocus={() => searchQuery.length >= 2 && setShowSearchResults(true)}
|
onFocus={() => searchQuery.length >= 2 && setShowSearchResults(true)}
|
||||||
className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted"
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key === 'Escape') {
|
||||||
</div>
|
setShowSearchResults(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
{/* Search Results Dropdown */}
|
||||||
{showSearchResults && (
|
{showSearchResults && (
|
||||||
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="max-h-[480px] overflow-y-auto p-2">
|
<div className="max-h-[480px] overflow-y-auto p-2">
|
||||||
{/* Organizations Section (Super Admin only) */}
|
{/* Organizations Section (Super Admin only) */}
|
||||||
{searchResults.organizations && searchResults.organizations.length > 0 && (
|
{Array.isArray(searchResults?.organizations) && searchResults.organizations.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
|
||||||
{searchResults.organizations.map(o => (
|
{searchResults.organizations.map(o => (
|
||||||
@@ -369,12 +379,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Members Section */}
|
{/* Members Section */}
|
||||||
{searchResults.members.length > 0 && (
|
{Array.isArray(searchResults?.members) && searchResults.members.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
|
||||||
{searchResults.members.map(m => {
|
{searchResults.members.map(m => {
|
||||||
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
||||||
const avatarSrc = m.avatar_url
|
const avatarSrc = m.avatar_url
|
||||||
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
|
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
|
||||||
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
|
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
|
||||||
|
|
||||||
@@ -388,9 +398,9 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
alt={m.name}
|
alt={m.name}
|
||||||
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
|
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
|
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
|
||||||
/>
|
/>
|
||||||
@@ -405,7 +415,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Teams Section */}
|
{/* Teams Section */}
|
||||||
{searchResults.teams.length > 0 && (
|
{Array.isArray(searchResults?.teams) && searchResults.teams.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Equipes</div>
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Equipes</div>
|
||||||
{searchResults.teams.map(t => (
|
{searchResults.teams.map(t => (
|
||||||
@@ -431,7 +441,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attendances Section */}
|
{/* Attendances Section */}
|
||||||
{searchResults.attendances.length > 0 && (
|
{Array.isArray(searchResults?.attendances) && searchResults.attendances.length > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Atendimentos</div>
|
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Atendimentos</div>
|
||||||
{searchResults.attendances.map(a => (
|
{searchResults.attendances.map(a => (
|
||||||
@@ -444,8 +454,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
className="w-full flex items-center gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-dark-border rounded-xl transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted text-[10px] font-bold border border-zinc-200 dark:border-dark-border">
|
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted border border-zinc-200 dark:border-dark-border">
|
||||||
KPI
|
<MessageSquare size={16} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
|
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
|
||||||
@@ -459,7 +469,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{searchResults.members.length === 0 && searchResults.teams.length === 0 && searchResults.attendances.length === 0 && (!searchResults.organizations || searchResults.organizations.length === 0) && (
|
{(!Array.isArray(searchResults?.members) || searchResults.members.length === 0) &&
|
||||||
|
(!Array.isArray(searchResults?.teams) || searchResults.teams.length === 0) &&
|
||||||
|
(!Array.isArray(searchResults?.attendances) || searchResults.attendances.length === 0) &&
|
||||||
|
(!Array.isArray(searchResults?.organizations) || searchResults.organizations.length === 0) && (
|
||||||
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
<div className="p-8 text-center text-zinc-500 dark:text-dark-muted text-sm">
|
||||||
Nenhum resultado encontrado para "{searchQuery}"
|
Nenhum resultado encontrado para "{searchQuery}"
|
||||||
</div>
|
</div>
|
||||||
@@ -13,12 +13,14 @@ interface SellerStat {
|
|||||||
|
|
||||||
interface SellersTableProps {
|
interface SellersTableProps {
|
||||||
data: SellerStat[];
|
data: SellerStat[];
|
||||||
|
defaultShowAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
export const SellersTable: React.FC<SellersTableProps> = ({ data, defaultShowAll = false }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [sortKey, setSortKey] = useState<keyof SellerStat>('total');
|
const [sortKey, setSortKey] = useState<keyof SellerStat>('conversionRate');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [showAll, setShowAll] = useState(defaultShowAll);
|
||||||
|
|
||||||
const handleSort = (key: keyof SellerStat) => {
|
const handleSort = (key: keyof SellerStat) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@@ -35,6 +37,11 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
const bVal = b[sortKey];
|
const bVal = b[sortKey];
|
||||||
|
|
||||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||||
|
if (sortKey === 'avgScore' || sortKey === 'conversionRate' || sortKey === 'responseTime') {
|
||||||
|
const numA = parseFloat(aVal) || 0;
|
||||||
|
const numB = parseFloat(bVal) || 0;
|
||||||
|
return sortDirection === 'asc' ? numA - numB : numB - numA;
|
||||||
|
}
|
||||||
return sortDirection === 'asc'
|
return sortDirection === 'asc'
|
||||||
? aVal.localeCompare(bVal)
|
? aVal.localeCompare(bVal)
|
||||||
: bVal.localeCompare(aVal);
|
: bVal.localeCompare(aVal);
|
||||||
@@ -55,6 +62,8 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
});
|
});
|
||||||
}, [data, sortKey, sortDirection]);
|
}, [data, sortKey, sortDirection]);
|
||||||
|
|
||||||
|
const displayedData = showAll ? sortedData : sortedData.slice(0, 5);
|
||||||
|
|
||||||
const SortIcon = ({ column }: { column: string }) => {
|
const SortIcon = ({ column }: { column: string }) => {
|
||||||
if (sortKey !== column) return <ChevronsUpDown size={14} className="text-zinc-300 dark:text-dark-muted" />;
|
if (sortKey !== column) return <ChevronsUpDown size={14} className="text-zinc-300 dark:text-dark-muted" />;
|
||||||
return sortDirection === 'asc' ? <ChevronUp size={14} className="text-brand-yellow" /> : <ChevronDown size={14} className="text-brand-yellow" />;
|
return sortDirection === 'asc' ? <ChevronUp size={14} className="text-brand-yellow" /> : <ChevronDown size={14} className="text-brand-yellow" />;
|
||||||
@@ -102,7 +111,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
{sortedData.map((item, idx) => (
|
{displayedData.map((item, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.user.id}
|
key={item.user.id}
|
||||||
className="hover:bg-yellow-50/10 dark:hover:bg-yellow-400/5 transition-colors cursor-pointer group"
|
className="hover:bg-yellow-50/10 dark:hover:bg-yellow-400/5 transition-colors cursor-pointer group"
|
||||||
@@ -110,7 +119,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-zinc-400 dark:text-dark-muted font-mono w-4">#{idx + 1}</span>
|
<span className="text-xs text-zinc-400 dark:text-dark-muted font-mono w-4">#{sortedData.indexOf(item) + 1}</span>
|
||||||
<img
|
<img
|
||||||
src={item.user.avatar_url
|
src={item.user.avatar_url
|
||||||
? (item.user.avatar_url.startsWith('http') ? item.user.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${item.user.avatar_url}`)
|
? (item.user.avatar_url.startsWith('http') ? item.user.avatar_url : `${import.meta.env.PROD ? '' : 'http://localhost:3001'}${item.user.avatar_url}`)
|
||||||
@@ -145,7 +154,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sortedData.length === 0 && (
|
{displayedData.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-8 text-center text-zinc-400 dark:text-dark-muted italic">Nenhum dado disponível para o período selecionado.</td>
|
<td colSpan={5} className="px-6 py-8 text-center text-zinc-400 dark:text-dark-muted italic">Nenhum dado disponível para o período selecionado.</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -153,6 +162,16 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{sortedData.length > 5 && (
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border bg-zinc-50/30 dark:bg-dark-bg flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
className="text-sm font-medium text-zinc-600 dark:text-dark-text hover:text-brand-yellow transition-colors"
|
||||||
|
>
|
||||||
|
{showAll ? 'Ver menos' : `Ver todos (${sortedData.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -10,7 +10,6 @@ import { COLORS } from '../constants';
|
|||||||
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types';
|
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types';
|
||||||
import { KPICard } from '../components/KPICard';
|
import { KPICard } from '../components/KPICard';
|
||||||
import { DateRangePicker } from '../components/DateRangePicker';
|
import { DateRangePicker } from '../components/DateRangePicker';
|
||||||
import { SellersTable } from '../components/SellersTable';
|
|
||||||
import { ProductLists } from '../components/ProductLists';
|
import { ProductLists } from '../components/ProductLists';
|
||||||
|
|
||||||
// Interface for seller statistics accumulator
|
// Interface for seller statistics accumulator
|
||||||
@@ -496,9 +495,6 @@ export const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ranking Table */}
|
|
||||||
<SellersTable data={sellersRanking} />
|
|
||||||
|
|
||||||
{/* Product Lists */}
|
{/* Product Lists */}
|
||||||
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
||||||
</div>
|
</div>
|
||||||
147
src/pages/Ranking.tsx
Normal file
147
src/pages/Ranking.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Filter, Users, Building2 } from 'lucide-react';
|
||||||
|
import { getAttendances, getUsers, getUserById, getTeams } from '../services/dataService';
|
||||||
|
import { Attendance, DashboardFilter, User } from '../types';
|
||||||
|
import { DateRangePicker } from '../components/DateRangePicker';
|
||||||
|
import { SellersTable } from '../components/SellersTable';
|
||||||
|
|
||||||
|
interface SellerStats {
|
||||||
|
total: number;
|
||||||
|
converted: number;
|
||||||
|
scoreSum: number;
|
||||||
|
count: number;
|
||||||
|
timeSum: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ranking: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState<Attendance[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [teams, setTeams] = useState<any[]>([]);
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<DashboardFilter>({
|
||||||
|
dateRange: {
|
||||||
|
start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), // Last 30 days
|
||||||
|
end: new Date(),
|
||||||
|
},
|
||||||
|
userId: 'all',
|
||||||
|
teamId: 'all',
|
||||||
|
funnelStage: 'all',
|
||||||
|
origin: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tenantId = localStorage.getItem('ctms_tenant_id');
|
||||||
|
const uid = localStorage.getItem('ctms_user_id');
|
||||||
|
|
||||||
|
if (tenantId && uid) {
|
||||||
|
const u = await getUserById(uid);
|
||||||
|
setCurrentUser(u || null);
|
||||||
|
|
||||||
|
const [fetchedAttendances, fetchedUsers, fetchedTeams] = await Promise.all([
|
||||||
|
getAttendances(tenantId, filters),
|
||||||
|
getUsers(tenantId),
|
||||||
|
getTeams(tenantId)
|
||||||
|
]);
|
||||||
|
setData(fetchedAttendances);
|
||||||
|
setUsers(fetchedUsers);
|
||||||
|
setTeams(fetchedTeams);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading ranking data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const sellersRanking = useMemo(() => {
|
||||||
|
const stats = data.reduce<Record<string, SellerStats>>((acc, curr) => {
|
||||||
|
if (!acc[curr.user_id]) {
|
||||||
|
acc[curr.user_id] = { total: 0, converted: 0, scoreSum: 0, count: 0, timeSum: 0 };
|
||||||
|
}
|
||||||
|
acc[curr.user_id].total += 1;
|
||||||
|
if (curr.converted) acc[curr.user_id].converted += 1;
|
||||||
|
acc[curr.user_id].scoreSum += curr.score;
|
||||||
|
acc[curr.user_id].timeSum += curr.first_response_time_min;
|
||||||
|
acc[curr.user_id].count += 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.entries(stats)
|
||||||
|
.map(([userId, s]) => {
|
||||||
|
const stat = s as SellerStats;
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (!user) return null;
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
total: stat.total,
|
||||||
|
avgScore: (stat.scoreSum / stat.count).toFixed(1),
|
||||||
|
conversionRate: ((stat.converted / stat.total) * 100).toFixed(1),
|
||||||
|
responseTime: (stat.timeSum / stat.count).toFixed(0)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||||
|
}, [data, users]);
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof DashboardFilter, value: any) => {
|
||||||
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && data.length === 0) {
|
||||||
|
return <div className="flex h-full items-center justify-center text-zinc-400 dark:text-dark-muted p-12">Carregando Ranking...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === 'admin' || currentUser?.role === 'super_admin' || currentUser?.role === 'manager';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-8 transition-colors duration-300">
|
||||||
|
<div className="bg-white dark:bg-dark-card p-4 rounded-xl shadow-sm border border-zinc-100 dark:border-dark-border flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-zinc-500 dark:text-dark-muted font-medium">
|
||||||
|
<Filter size={18} />
|
||||||
|
<span>Filtros:</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 w-full md:w-auto">
|
||||||
|
<DateRangePicker
|
||||||
|
dateRange={filters.dateRange}
|
||||||
|
onChange={(range) => handleFilterChange('dateRange', range)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Todos Usuários</option>
|
||||||
|
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{currentUser?.role !== 'manager' && (
|
||||||
|
<select
|
||||||
|
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
|
||||||
|
value={filters.teamId}
|
||||||
|
onChange={(e) => handleFilterChange('teamId', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Todas Equipes</option>
|
||||||
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SellersTable data={sellersRanking} defaultShowAll={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ";
|
|
||||||
const base64Url = token.split('.')[1];
|
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
console.log(base64);
|
|
||||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
||||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
||||||
}).join(''));
|
|
||||||
console.log(JSON.parse(jsonPayload));
|
|
||||||
11
test-jwt.js
11
test-jwt.js
@@ -1,11 +0,0 @@
|
|||||||
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InVfMTIzNDUiLCJ0ZW5hbnRfaWQiOiJzeXN0ZW0ifQ.XYZ";
|
|
||||||
const base64Url = token.split('.')[1];
|
|
||||||
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const pad = base64.length % 4;
|
|
||||||
if (pad) {
|
|
||||||
base64 += '='.repeat(4 - pad);
|
|
||||||
}
|
|
||||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
||||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
||||||
}).join(''));
|
|
||||||
console.log(JSON.parse(jsonPayload));
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
const pool = mysql.createPool({ host: 'localhost', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
const id = 'u_71657ec7'; // or your ID
|
|
||||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [id, id]);
|
|
||||||
console.log(rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']);
|
|
||||||
console.log("ROWS:", rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("ERROR:", err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test();
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const pool = mysql.createPool({ host: '127.0.0.1', user: 'root', password: 'secret_pass', database: 'fasto_db', port: 3306 });
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', ['u_71657ec7', 'u_71657ec7']);
|
|
||||||
console.log("ROWS:", rows);
|
|
||||||
// Simulate req.user
|
|
||||||
const req = { user: { role: 'super_admin', tenant_id: 'system' } };
|
|
||||||
|
|
||||||
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
|
|
||||||
console.log("Access Denied");
|
|
||||||
} else {
|
|
||||||
console.log("Access Granted");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("ERROR:", err);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test();
|
|
||||||
Reference in New Issue
Block a user