refactor: remove mock data and finalize n8n data schema
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m50s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m50s
- Removed all hardcoded MOCK_ATTENDANCES, USERS, and TENANTS generators from constants.ts since the system is now production-ready. - Renamed 'summary' to 'title' in the database and across all frontend components for clarity. - Added 'full_summary' to the attendances schema to explicitly store the large, detailed AI analysis texts from n8n. - Updated the 'Resumo da Interação' UI to render the 'full_summary' without adding any artificial filler text. - Localized all dates and times across the dashboard to Brazilian formatting (pt-BR).
This commit is contained in:
@@ -796,7 +796,7 @@ apiRouter.get('/search', async (req, res) => {
|
||||
}
|
||||
|
||||
// 4. Search Attendances
|
||||
let attendancesQ = 'SELECT a.id, a.summary, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.summary LIKE ?';
|
||||
let attendancesQ = 'SELECT a.id, a.title, a.created_at, u.name as user_name FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.title LIKE ?';
|
||||
const attendancesParams = [queryStr];
|
||||
|
||||
if (req.user.role === 'super_admin') {
|
||||
@@ -959,7 +959,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
||||
user_id,
|
||||
origin,
|
||||
funnel_stage,
|
||||
summary,
|
||||
title,
|
||||
full_summary,
|
||||
score,
|
||||
first_response_time_min,
|
||||
handling_time_min,
|
||||
@@ -970,8 +971,8 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
||||
improvement_points
|
||||
} = req.body;
|
||||
|
||||
if (!user_id || !origin || !funnel_stage || !summary) {
|
||||
return res.status(400).json({ error: 'Campos obrigatórios ausentes: 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, title' });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -982,16 +983,17 @@ apiRouter.post('/integration/attendances', requireRole(['admin']), async (req, r
|
||||
const attId = `att_${crypto.randomUUID().split('-')[0]}`;
|
||||
await pool.query(
|
||||
`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,
|
||||
funnel_stage, origin, product_requested, product_sold,
|
||||
converted, attention_points, improvement_points
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attId,
|
||||
req.user.tenant_id,
|
||||
user_id,
|
||||
summary,
|
||||
title,
|
||||
full_summary || null,
|
||||
score || 0,
|
||||
first_response_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);
|
||||
}
|
||||
|
||||
// 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
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS funnels (
|
||||
|
||||
@@ -443,10 +443,10 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
KPI
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.summary}</div>
|
||||
<div className="text-sm font-semibold text-zinc-900 dark:text-dark-text truncate">{a.title}</div>
|
||||
<div className="text-[10px] text-zinc-500 dark:text-dark-muted flex justify-between mt-0.5">
|
||||
<span className="font-medium">{a.user_name}</span>
|
||||
<span>{new Date(a.created_at).toLocaleDateString()}</span>
|
||||
<span>{new Date(a.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -541,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'}
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-dark-muted">
|
||||
{new Date(n.created_at).toLocaleDateString()}
|
||||
{new Date(n.created_at).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-zinc-900 dark:text-dark-text mb-0.5">{n.title}</div>
|
||||
|
||||
161
constants.ts
161
constants.ts
@@ -1,164 +1,5 @@
|
||||
|
||||
import { Attendance, FunnelStage, Tenant, User } from './types';
|
||||
|
||||
export const TENANTS: Tenant[] = [
|
||||
{
|
||||
id: 'tenant_123',
|
||||
name: 'Fasto Corp',
|
||||
slug: 'fasto',
|
||||
admin_email: 'admin@fasto.com',
|
||||
status: 'active',
|
||||
user_count: 12,
|
||||
attendance_count: 1450,
|
||||
created_at: '2023-01-15T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'tenant_456',
|
||||
name: 'Acme Inc',
|
||||
slug: 'acme-inc',
|
||||
admin_email: 'contact@acme.com',
|
||||
status: 'trial',
|
||||
user_count: 5,
|
||||
attendance_count: 320,
|
||||
created_at: '2023-06-20T14:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'tenant_789',
|
||||
name: 'Globex Utils',
|
||||
slug: 'globex',
|
||||
admin_email: 'sysadmin@globex.com',
|
||||
status: 'inactive',
|
||||
user_count: 2,
|
||||
attendance_count: 45,
|
||||
created_at: '2022-11-05T09:15:00Z'
|
||||
},
|
||||
{
|
||||
id: 'tenant_101',
|
||||
name: 'Soylent Green',
|
||||
slug: 'soylent',
|
||||
admin_email: 'admin@soylent.com',
|
||||
status: 'active',
|
||||
user_count: 25,
|
||||
attendance_count: 5600,
|
||||
created_at: '2023-02-10T11:20:00Z'
|
||||
},
|
||||
];
|
||||
|
||||
export const USERS: User[] = [
|
||||
{
|
||||
id: 'sa1',
|
||||
tenant_id: 'system',
|
||||
name: 'Super Administrator',
|
||||
email: 'root@system.com',
|
||||
role: 'super_admin',
|
||||
team_id: '',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=Super+Admin&background=0f172a&color=fff',
|
||||
bio: 'Administrador do Sistema Global',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'u1',
|
||||
tenant_id: 'tenant_123',
|
||||
name: 'Lidya Chan',
|
||||
email: 'lidya@fasto.com',
|
||||
role: 'manager',
|
||||
team_id: 'sales_1',
|
||||
avatar_url: 'https://picsum.photos/id/1011/200/200',
|
||||
bio: 'Gerente de Vendas com mais de 10 anos de experiência em SaaS. Apaixonada por construção de equipes e crescimento de receita.',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
tenant_id: 'tenant_123',
|
||||
name: 'Alex Noer',
|
||||
email: 'alex@fasto.com',
|
||||
role: 'agent',
|
||||
team_id: 'sales_1',
|
||||
avatar_url: 'https://picsum.photos/id/1012/200/200',
|
||||
bio: 'Melhor desempenho no Q3. Focado em clientes corporativos e relacionamentos de longo prazo.',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'u3',
|
||||
tenant_id: 'tenant_123',
|
||||
name: 'Angela Moss',
|
||||
email: 'angela@fasto.com',
|
||||
role: 'agent',
|
||||
team_id: 'sales_1',
|
||||
avatar_url: 'https://picsum.photos/id/1013/200/200',
|
||||
status: 'inactive'
|
||||
},
|
||||
{
|
||||
id: 'u4',
|
||||
tenant_id: 'tenant_123',
|
||||
name: 'Brian Samuel',
|
||||
email: 'brian@fasto.com',
|
||||
role: 'agent',
|
||||
team_id: 'sales_2',
|
||||
avatar_url: 'https://picsum.photos/id/1014/200/200',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'u5',
|
||||
tenant_id: 'tenant_123',
|
||||
name: 'Benny Chagur',
|
||||
email: 'benny@fasto.com',
|
||||
role: 'agent',
|
||||
team_id: 'sales_2',
|
||||
avatar_url: 'https://picsum.photos/id/1025/200/200',
|
||||
status: 'active'
|
||||
},
|
||||
];
|
||||
|
||||
const generateMockAttendances = (count: number): Attendance[] => {
|
||||
const origins = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação'] as const;
|
||||
const stages = Object.values(FunnelStage);
|
||||
const products = ['Plano Premium', 'Plano Básico', 'Suíte Enterprise', 'Consultoria'];
|
||||
|
||||
return Array.from({ length: count }).map((_, i) => {
|
||||
const user = USERS.slice(1)[Math.floor(Math.random() * (USERS.length - 1))]; // Skip super admin
|
||||
const rand = Math.random();
|
||||
// Weighted stages for realism
|
||||
let stage = FunnelStage.IDENTIFICATION;
|
||||
let isConverted = false;
|
||||
|
||||
if (rand > 0.85) {
|
||||
stage = FunnelStage.WON;
|
||||
isConverted = true;
|
||||
} else if (rand > 0.7) {
|
||||
stage = FunnelStage.LOST;
|
||||
} else if (rand > 0.5) {
|
||||
stage = FunnelStage.NEGOTIATION;
|
||||
} else if (rand > 0.2) {
|
||||
stage = FunnelStage.IDENTIFICATION;
|
||||
} else {
|
||||
stage = FunnelStage.NO_CONTACT;
|
||||
}
|
||||
|
||||
// Force won/lost logic consistency
|
||||
if (stage === FunnelStage.WON) isConverted = true;
|
||||
|
||||
return {
|
||||
id: `att_${i}`,
|
||||
tenant_id: 'tenant_123',
|
||||
user_id: user.id,
|
||||
created_at: new Date(Date.now() - Math.floor(Math.random() * 60 * 24 * 60 * 60 * 1000)).toISOString(),
|
||||
summary: "Cliente perguntou sobre detalhes do produto e níveis de preços.",
|
||||
attention_points: Math.random() > 0.8 ? ["Resposta demorada", "Verificar tom de voz"] : [],
|
||||
improvement_points: ["Sugerir plano anual", "Fazer follow-up mais cedo"],
|
||||
score: Math.floor(Math.random() * (100 - 50) + 50),
|
||||
first_response_time_min: Math.floor(Math.random() * 120),
|
||||
handling_time_min: Math.floor(Math.random() * 45),
|
||||
funnel_stage: stage,
|
||||
origin: origins[Math.floor(Math.random() * origins.length)],
|
||||
product_requested: products[Math.floor(Math.random() * products.length)],
|
||||
product_sold: isConverted ? products[Math.floor(Math.random() * products.length)] : undefined,
|
||||
converted: isConverted,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const MOCK_ATTENDANCES = generateMockAttendances(300);
|
||||
import { FunnelStage } from './types';
|
||||
|
||||
// Visual Constants
|
||||
export const COLORS = {
|
||||
|
||||
@@ -169,7 +169,7 @@ export const ApiKeys: React.FC = () => {
|
||||
</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() : 'Nunca'}
|
||||
{key.last_used_at ? new Date(key.last_used_at).toLocaleString('pt-BR') : 'Nunca'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
|
||||
@@ -98,7 +98,7 @@ export const AttendanceDetail: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-dark-text leading-tight">
|
||||
{data.summary}
|
||||
{data.title}
|
||||
</h1>
|
||||
{agent && (
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
@@ -136,11 +136,17 @@ export const AttendanceDetail: React.FC = () => {
|
||||
<MessageSquare size={18} className="text-zinc-400 dark:text-dark-muted" />
|
||||
Resumo da Interação
|
||||
</h3>
|
||||
<p className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm">
|
||||
{data.summary} O cliente perguntou sobre detalhes específicos relacionados ao <span className="font-medium text-zinc-800 dark:text-zinc-100">{data.product_requested}</span>.
|
||||
<div className="text-zinc-600 dark:text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap">
|
||||
{data.full_summary ? (
|
||||
data.full_summary
|
||||
) : (
|
||||
<>
|
||||
{data.title} O cliente perguntou sobre detalhes específicos relacionados ao <span className="font-medium text-zinc-800 dark:text-zinc-100">{data.product_requested}</span>.
|
||||
As discussões envolveram níveis de preços, prazos de implementação e potenciais descontos por volume.
|
||||
A interação foi concluída com {data.converted ? 'uma venda realizada' : 'o cliente pedindo mais tempo para decidir'}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Section */}
|
||||
|
||||
@@ -214,16 +214,14 @@ export const UserDetail: React.FC = () => {
|
||||
{currentData.map(att => (
|
||||
<tr key={att.id} className="hover:bg-zinc-50/80 dark:hover:bg-zinc-800/50 transition-colors group">
|
||||
<td className="px-6 py-4 text-zinc-600 dark:text-zinc-300 whitespace-nowrap">
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString()}</div>
|
||||
<div className="text-xs text-zinc-400 dark:text-dark-muted">{new Date(att.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</div>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">{new Date(att.created_at).toLocaleDateString('pt-BR')}</div>
|
||||
<div className="text-xs text-zinc-400 dark:text-dark-muted">{new Date(att.created_at).toLocaleTimeString('pt-BR', {hour: '2-digit', minute:'2-digit', hour12: false})}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.summary}</span>
|
||||
<span className="text-zinc-800 dark:text-zinc-200 line-clamp-1 font-medium mb-1">{att.title}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500 dark:text-dark-muted">
|
||||
<span className="flex items-center gap-1"><MessageSquare size={10} /> {att.origin}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span className={`inline-flex px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getStageBadgeColor(att.funnel_stage)}`}>
|
||||
|
||||
5
types.ts
5
types.ts
@@ -42,13 +42,14 @@ export interface Attendance {
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
created_at: string; // ISO Date
|
||||
summary: string;
|
||||
title: string;
|
||||
full_summary?: string;
|
||||
attention_points: string[];
|
||||
improvement_points: string[];
|
||||
score: number; // 0-100
|
||||
first_response_time_min: number;
|
||||
handling_time_min: number;
|
||||
funnel_stage: FunnelStage;
|
||||
funnel_stage: string;
|
||||
origin: 'WhatsApp' | 'Instagram' | 'Website' | 'LinkedIn' | 'Indicação';
|
||||
product_requested: string;
|
||||
product_sold?: string;
|
||||
|
||||
Reference in New Issue
Block a user