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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1013,6 +1013,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
attendancesQ += ' AND a.user_id = ?';
|
||||
attendancesParams.push(req.user.id);
|
||||
}
|
||||
attendancesQ += ' LIMIT 10';
|
||||
const [attendances] = await pool.query(attendancesQ, attendancesParams);
|
||||
results.attendances = attendances;
|
||||
|
||||
@@ -1047,17 +1048,16 @@ apiRouter.get('/attendances', async (req, res) => {
|
||||
params.push(teamId);
|
||||
}
|
||||
|
||||
if (userId && userId !== 'all') {
|
||||
if (userId && userId !== 'all') {
|
||||
// check if it's a slug or id
|
||||
if (userId.startsWith('u_')) {
|
||||
q += ' AND a.user_id = ?';
|
||||
params.push(userId);
|
||||
if (userId.startsWith('u_') || userId.length === 36) {
|
||||
q += ' AND a.user_id = ?';
|
||||
params.push(userId);
|
||||
} else {
|
||||
q += ' AND u.slug = ?';
|
||||
params.push(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
|
||||
if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); }
|
||||
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
|
||||
INIT_BACKUP: "1" # Faz um backup imediatamente ao ligar o container
|
||||
volumes:
|
||||
- /opt/backups_db/fasto:/backup
|
||||
- /opt/backups_db/fastogemi:/backup
|
||||
networks:
|
||||
- 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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ForgotPassword } from "./pages/ForgotPassword";
|
||||
import { ResetPassword } from "./pages/ResetPassword";
|
||||
import { SetupAccount } from "./pages/SetupAccount";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { Ranking } from "./pages/Ranking";
|
||||
import { getUserById, logout } from "./services/dataService";
|
||||
import { User } from "./types";
|
||||
|
||||
@@ -127,6 +128,14 @@ const App: React.FC = () => {
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ranking"
|
||||
element={
|
||||
<AuthGuard roles={["super_admin", "admin", "manager"]}>
|
||||
<Ranking />
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/teams"
|
||||
element={
|
||||
@@ -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, Key, Target
|
||||
ChevronLeft, ChevronRight, Key, Target, Trophy, MessageSquare
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
deleteNotification, clearAllNotifications, returnToSuperAdmin
|
||||
} from '../services/dataService';
|
||||
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 }) => (
|
||||
<NavLink
|
||||
@@ -235,10 +235,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
<SidebarItem to="/" icon={LayoutDashboard} label="Dashboard" collapsed={isSidebarCollapsed} />
|
||||
|
||||
{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/users" icon={Users} label="Membros" collapsed={isSidebarCollapsed} />
|
||||
|
||||
{currentUser.role !== 'manager' && (
|
||||
<>
|
||||
<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">
|
||||
{isSearching ? <Loader2 size={18} className="text-brand-yellow animate-spin" /> : <Search size={18} className="text-zinc-400 dark:text-dark-muted" />}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={getSearchPlaceholder()}
|
||||
type="text"
|
||||
placeholder={getSearchPlaceholder()}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => searchQuery.length >= 2 && setShowSearchResults(true)}
|
||||
className="bg-transparent border-none outline-none text-sm ml-3 w-full text-zinc-700 dark:text-dark-text placeholder-zinc-400 dark:placeholder-dark-muted"
|
||||
/>
|
||||
</div>
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
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 */}
|
||||
{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">
|
||||
{/* Search Results Dropdown */}
|
||||
{showSearchResults && (
|
||||
<div className="absolute top-full mt-2 left-0 w-full bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="max-h-[480px] overflow-y-auto p-2">
|
||||
{/* Organizations Section (Super Admin only) */}
|
||||
{searchResults.organizations && searchResults.organizations.length > 0 && (
|
||||
{Array.isArray(searchResults?.organizations) && searchResults.organizations.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Organizações</div>
|
||||
{searchResults.organizations.map(o => (
|
||||
@@ -369,12 +379,12 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
)}
|
||||
|
||||
{/* Members Section */}
|
||||
{searchResults.members.length > 0 && (
|
||||
{Array.isArray(searchResults?.members) && searchResults.members.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Membros</div>
|
||||
{searchResults.members.map(m => {
|
||||
const backendUrl = import.meta.env.PROD ? '' : 'http://localhost:3001';
|
||||
const avatarSrc = m.avatar_url
|
||||
const avatarSrc = m.avatar_url
|
||||
? (m.avatar_url.startsWith('http') ? m.avatar_url : `${backendUrl}${m.avatar_url}`)
|
||||
: `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={m.name}
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={m.name}
|
||||
className="w-9 h-9 rounded-full border border-zinc-100 dark:border-dark-border object-cover"
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(m.name)}&background=random`; }}
|
||||
/>
|
||||
@@ -405,7 +415,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
)}
|
||||
|
||||
{/* Teams Section */}
|
||||
{searchResults.teams.length > 0 && (
|
||||
{Array.isArray(searchResults?.teams) && searchResults.teams.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Equipes</div>
|
||||
{searchResults.teams.map(t => (
|
||||
@@ -431,7 +441,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
)}
|
||||
|
||||
{/* Attendances Section */}
|
||||
{searchResults.attendances.length > 0 && (
|
||||
{Array.isArray(searchResults?.attendances) && searchResults.attendances.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest border-b border-zinc-50 dark:border-dark-border/50 mb-1">Atendimentos</div>
|
||||
{searchResults.attendances.map(a => (
|
||||
@@ -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"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-dark-bg flex items-center justify-center text-zinc-500 dark:text-dark-muted text-[10px] font-bold border border-zinc-200 dark:border-dark-border">
|
||||
KPI
|
||||
<div 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">
|
||||
<MessageSquare size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
|
||||
@@ -459,7 +469,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
</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">
|
||||
Nenhum resultado encontrado para "{searchQuery}"
|
||||
</div>
|
||||
@@ -13,12 +13,14 @@ interface SellerStat {
|
||||
|
||||
interface SellersTableProps {
|
||||
data: SellerStat[];
|
||||
defaultShowAll?: boolean;
|
||||
}
|
||||
|
||||
export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
export const SellersTable: React.FC<SellersTableProps> = ({ data, defaultShowAll = false }) => {
|
||||
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 [showAll, setShowAll] = useState(defaultShowAll);
|
||||
|
||||
const handleSort = (key: keyof SellerStat) => {
|
||||
if (sortKey === key) {
|
||||
@@ -35,6 +37,11 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
const bVal = b[sortKey];
|
||||
|
||||
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'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
@@ -55,6 +62,8 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
});
|
||||
}, [data, sortKey, sortDirection]);
|
||||
|
||||
const displayedData = showAll ? sortedData : sortedData.slice(0, 5);
|
||||
|
||||
const SortIcon = ({ column }: { column: string }) => {
|
||||
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" />;
|
||||
@@ -102,7 +111,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||
{sortedData.map((item, idx) => (
|
||||
{displayedData.map((item, idx) => (
|
||||
<tr
|
||||
key={item.user.id}
|
||||
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">
|
||||
<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
|
||||
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}`)
|
||||
@@ -145,7 +154,7 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sortedData.length === 0 && (
|
||||
{displayedData.length === 0 && (
|
||||
<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>
|
||||
</tr>
|
||||
@@ -153,6 +162,16 @@ export const SellersTable: React.FC<SellersTableProps> = ({ data }) => {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import { COLORS } from '../constants';
|
||||
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef, OriginItemDef } from '../types';
|
||||
import { KPICard } from '../components/KPICard';
|
||||
import { DateRangePicker } from '../components/DateRangePicker';
|
||||
import { SellersTable } from '../components/SellersTable';
|
||||
import { ProductLists } from '../components/ProductLists';
|
||||
|
||||
// Interface for seller statistics accumulator
|
||||
@@ -496,9 +495,6 @@ export const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ranking Table */}
|
||||
<SellersTable data={sellersRanking} />
|
||||
|
||||
{/* Product Lists */}
|
||||
<ProductLists requested={productStats.requested} sold={productStats.sold} />
|
||||
</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