Compare commits
3 Commits
ef6d1582b3
...
96cfb3d125
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cfb3d125 | ||
|
|
baa1bd66f6 | ||
|
|
fbf3edb7a1 |
2
App.tsx
2
App.tsx
@@ -5,6 +5,7 @@ import { Dashboard } from './pages/Dashboard';
|
|||||||
import { UserDetail } from './pages/UserDetail';
|
import { UserDetail } from './pages/UserDetail';
|
||||||
import { AttendanceDetail } from './pages/AttendanceDetail';
|
import { AttendanceDetail } from './pages/AttendanceDetail';
|
||||||
import { SuperAdmin } from './pages/SuperAdmin';
|
import { SuperAdmin } from './pages/SuperAdmin';
|
||||||
|
import { ApiKeys } from './pages/ApiKeys';
|
||||||
import { TeamManagement } from './pages/TeamManagement';
|
import { TeamManagement } from './pages/TeamManagement';
|
||||||
import { Teams } from './pages/Teams';
|
import { Teams } from './pages/Teams';
|
||||||
import { Funnels } from './pages/Funnels';
|
import { Funnels } from './pages/Funnels';
|
||||||
@@ -81,6 +82,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
<Route path="/users/:id" element={<AuthGuard><UserDetail /></AuthGuard>} />
|
||||||
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
<Route path="/attendances/:id" element={<AuthGuard><AttendanceDetail /></AuthGuard>} />
|
||||||
<Route path="/super-admin" element={<AuthGuard roles={['super_admin']}><SuperAdmin /></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="/profile" element={<AuthGuard><UserProfile /></AuthGuard>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ apiRouter.get('/search', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Search Attendances
|
// 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];
|
const attendancesParams = [queryStr];
|
||||||
|
|
||||||
if (req.user.role === 'super_admin') {
|
if (req.user.role === 'super_admin') {
|
||||||
@@ -959,7 +959,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
|||||||
user_id,
|
user_id,
|
||||||
origin,
|
origin,
|
||||||
funnel_stage,
|
funnel_stage,
|
||||||
summary,
|
title,
|
||||||
|
full_summary,
|
||||||
score,
|
score,
|
||||||
first_response_time_min,
|
first_response_time_min,
|
||||||
handling_time_min,
|
handling_time_min,
|
||||||
@@ -970,8 +971,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
|||||||
improvement_points
|
improvement_points
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!user_id || !origin || !funnel_stage || !summary) {
|
if (!user_id || !origin || !funnel_stage || !title) {
|
||||||
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, summary' });
|
return res.status(400).json({ error: 'Campos obrigatórios ausentes: user_id, origin, funnel_stage, title' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -982,16 +983,17 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
|||||||
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO attendances (
|
`INSERT INTO attendances (
|
||||||
id, tenant_id, user_id, summary, score,
|
id, tenant_id, user_id, title, full_summary, score,
|
||||||
first_response_time_min, handling_time_min,
|
first_response_time_min, handling_time_min,
|
||||||
funnel_stage, origin, product_requested, product_sold,
|
funnel_stage, origin, product_requested, product_sold,
|
||||||
converted, attention_points, improvement_points
|
converted, attention_points, improvement_points
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
attId,
|
attId,
|
||||||
req.user.tenant_id,
|
req.user.tenant_id,
|
||||||
user_id,
|
user_id,
|
||||||
summary,
|
title,
|
||||||
|
full_summary || null,
|
||||||
score || 0,
|
score || 0,
|
||||||
first_response_time_min || 0,
|
first_response_time_min || 0,
|
||||||
handling_time_min || 0,
|
handling_time_min || 0,
|
||||||
@@ -1300,6 +1302,27 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
|
|||||||
console.log('Schema update note (funnel_stage):', err.message);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Create funnels table
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS funnels (
|
CREATE TABLE IF NOT EXISTS funnels (
|
||||||
|
|||||||
@@ -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
|
ChevronLeft, ChevronRight, Key
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAttendances, getUsers, getUserById, logout, searchGlobal,
|
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 }) => (
|
const SidebarItem = ({ to, icon: Icon, label, collapsed }: { to: string, icon: any, label: string, collapsed: boolean }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
|
`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group ${
|
||||||
isActive
|
isActive
|
||||||
@@ -249,14 +250,14 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
<>
|
<>
|
||||||
{!isSidebarCollapsed && (
|
{!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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarItem to="/super-admin" icon={Building2} label="Organizações" collapsed={isSidebarCollapsed} />
|
<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="/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 */}
|
{/* 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">
|
<div className="p-3 border-t border-zinc-100 dark:border-dark-border space-y-3 shrink-0">
|
||||||
@@ -442,10 +443,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
KPI
|
KPI
|
||||||
</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.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">
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -540,7 +541,7 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
|
{n.type === 'success' ? 'SUCESSO' : n.type === 'warning' ? 'AVISO' : n.type === 'error' ? 'ERRO' : 'INFO'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</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';
|
import { FunnelStage } 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);
|
|
||||||
|
|
||||||
// Visual Constants
|
// Visual Constants
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
|
|||||||
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
|
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
|
||||||
{data.summary}
|
{data.title}
|
||||||
</h1>
|
</h1>
|
||||||
{agent && (
|
{agent && (
|
||||||
<div className="flex items-center gap-3 pt-2">
|
<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" />
|
<MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" />
|
||||||
Resumo da Interação
|
Resumo da Interação
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm">
|
<div className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap">
|
||||||
{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>.
|
{data.full_summary ? (
|
||||||
As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume.
|
data.full_summary
|
||||||
A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}.
|
) : (
|
||||||
</p>
|
<>
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Feedback Section */}
|
{/* Feedback Section */}
|
||||||
|
|||||||
@@ -214,15 +214,13 @@ export const UserDetail: React.FC = () => {
|
|||||||
{currentData.map(att => (
|
{currentData.map(att => (
|
||||||
<tr key={att.id} className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50 transition-colors group">
|
<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">
|
<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="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([], {hour: '2-digit', minute:'2-digit'})}</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>
|
||||||
<td className="px-6 py-4">
|
<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.title}</span>
|
||||||
<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">
|
||||||
<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>
|
||||||
<span className="flex items-center gap-1"><MessageSquare size={10} /> {att.origin}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-center">
|
<td className="px-6 py-4 text-center">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell, Key, Trash2, Copy, Plus } from 'lucide-react';
|
import { Camera, Save, Mail, User as UserIcon, Building, Shield, Loader2, CheckCircle2, Bell } from 'lucide-react';
|
||||||
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar, getApiKeys, createApiKey, deleteApiKey } from '../services/dataService';
|
import { getUserById, getTenants, getTeams, updateUser, uploadAvatar } from '../services/dataService';
|
||||||
import { User, Tenant } from '../types';
|
import { User, Tenant } from '../types';
|
||||||
|
|
||||||
export const UserProfile: React.FC = () => {
|
export const UserProfile: React.FC = () => {
|
||||||
@@ -16,25 +16,9 @@ export const UserProfile: React.FC = () => {
|
|||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
// API Keys state
|
|
||||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
|
||||||
const [isGeneratingKey, setIsGeneratingKey] = useState(false);
|
|
||||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchApiKeys = async (tenantId: string) => {
|
|
||||||
try {
|
|
||||||
const keys = await getApiKeys(tenantId);
|
|
||||||
setApiKeys(keys);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to load API keys", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserAndTenant = async () => {
|
const fetchUserAndTenant = async () => {
|
||||||
const storedUserId = localStorage.getItem('ctms_user_id');
|
const storedUserId = localStorage.getItem('ctms_user_id');
|
||||||
const storedTenantId = localStorage.getItem('ctms_tenant_id');
|
|
||||||
|
|
||||||
if (storedUserId) {
|
if (storedUserId) {
|
||||||
try {
|
try {
|
||||||
@@ -45,12 +29,6 @@ export const UserProfile: React.FC = () => {
|
|||||||
setBio(fetchedUser.bio || '');
|
setBio(fetchedUser.bio || '');
|
||||||
setEmail(fetchedUser.email);
|
setEmail(fetchedUser.email);
|
||||||
|
|
||||||
if (fetchedUser.role === 'admin' || fetchedUser.role === 'super_admin') {
|
|
||||||
if (storedTenantId) {
|
|
||||||
fetchApiKeys(storedTenantId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch tenant info
|
// Fetch tenant info
|
||||||
const tenants = await getTenants();
|
const tenants = await getTenants();
|
||||||
const userTenant = tenants.find(t => t.id === fetchedUser.tenant_id);
|
const userTenant = tenants.find(t => t.id === fetchedUser.tenant_id);
|
||||||
@@ -72,36 +50,6 @@ export const UserProfile: React.FC = () => {
|
|||||||
fetchUserAndTenant();
|
fetchUserAndTenant();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleGenerateApiKey = async () => {
|
|
||||||
if (!newKeyName.trim() || !tenant) return;
|
|
||||||
setIsGeneratingKey(true);
|
|
||||||
try {
|
|
||||||
const res = await createApiKey({ name: newKeyName, tenantId: tenant.id });
|
|
||||||
setGeneratedKey(res.secret_key);
|
|
||||||
setNewKeyName('');
|
|
||||||
fetchApiKeys(tenant.id);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Erro ao gerar chave.');
|
|
||||||
} finally {
|
|
||||||
setIsGeneratingKey(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevokeApiKey = async (id: string) => {
|
|
||||||
if (!tenant) return;
|
|
||||||
if (confirm('Tem certeza? Todas as integrações usando esta chave pararão de funcionar imediatamente.')) {
|
|
||||||
const success = await deleteApiKey(id);
|
|
||||||
if (success) {
|
|
||||||
fetchApiKeys(tenant.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
alert('Chave copiada para a área de transferência!');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
const handleAvatarClick = () => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
@@ -373,104 +321,6 @@ export const UserProfile: React.FC = () => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Keys Section (Only for Admins/Super Admins) */}
|
|
||||||
{(user.role === 'admin' || user.role === 'super_admin') && (
|
|
||||||
<div className="lg:col-span-3">
|
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-zinc-200 dark:border-zinc-800 overflow-hidden transition-colors">
|
|
||||||
<div className="p-6 md:p-8">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-xl">
|
|
||||||
<Key size={24} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">Integrações via API (n8n, etc)</h2>
|
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
|
||||||
Gerencie as chaves de acesso para que sistemas externos possam enviar dados (como atendimentos) para sua organização.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{generatedKey && (
|
|
||||||
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl">
|
|
||||||
<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">
|
|
||||||
{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" 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: n8n WhatsApp)"
|
|
||||||
value={newKeyName}
|
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
|
||||||
className="flex-1 p-3 border border-zinc-200 dark:border-zinc-800 rounded-lg bg-zinc-50 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-brand-yellow/20 focus:border-brand-yellow sm:text-sm transition-all"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleGenerateApiKey}
|
|
||||||
disabled={isGeneratingKey || !newKeyName.trim()}
|
|
||||||
className="px-6 py-3 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
{isGeneratingKey ? <Loader2 className="animate-spin" size={18} /> : <Plus size={18} />}
|
|
||||||
Gerar Chave
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{apiKeys.length > 0 ? (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-200 dark:border-zinc-800">
|
|
||||||
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Nome</th>
|
|
||||||
<th className="py-3 px-4 text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Chave</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-zinc-800/50">
|
|
||||||
{apiKeys.map(key => (
|
|
||||||
<tr key={key.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
|
|
||||||
<td className="py-3 px-4 text-sm font-medium text-zinc-900 dark:text-zinc-100">{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).toLocaleDateString() : '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-8 border border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl text-zinc-500 dark:text-zinc-400">
|
|
||||||
<Key size={32} className="mx-auto mb-3 opacity-20" />
|
|
||||||
Nenhuma chave de API gerada.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
5
types.ts
5
types.ts
@@ -42,13 +42,14 @@ export interface Attendance {
|
|||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string; // ISO Date
|
created_at: string; // ISO Date
|
||||||
summary: string;
|
title: string;
|
||||||
|
full_summary?: string;
|
||||||
attention_points: string[];
|
attention_points: string[];
|
||||||
improvement_points: string[];
|
improvement_points: string[];
|
||||||
score: number; // 0-100
|
score: number; // 0-100
|
||||||
first_response_time_min: number;
|
first_response_time_min: number;
|
||||||
handling_time_min: number;
|
handling_time_min: number;
|
||||||
funnel_stage: FunnelStage;
|
funnel_stage: string;
|
||||||
origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação';
|
origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação';
|
||||||
product_requested: string;
|
product_requested: string;
|
||||||
product_sold?: string;
|
product_sold?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user