feat: Add Analytics, Odoo Integration, A/B Testing, and Content features
Phase 1 - Analytics y Reportes: - PostMetrics and AnalyticsReport models for tracking engagement - Analytics service with dashboard stats, top posts, optimal times - 8 API endpoints at /api/analytics/* - Interactive dashboard with Chart.js charts - Celery tasks for metrics fetch (15min) and weekly reports Phase 2 - Integración Odoo: - Lead and OdooSyncLog models for CRM integration - Odoo fields added to Product and Service models - XML-RPC service for bidirectional sync - Lead management API at /api/leads/* - Leads dashboard template - Celery tasks for product/service sync and lead export Phase 3 - A/B Testing y Recycling: - ABTest, ABTestVariant, RecycledPost models - Statistical winner analysis using chi-square test - Content recycling with engagement-based scoring - APIs at /api/ab-tests/* and /api/recycling/* - Automated test evaluation and content recycling tasks Phase 4 - Thread Series y Templates: - ThreadSeries and ThreadPost models for multi-post threads - AI-powered thread generation - Enhanced ImageTemplate with HTML template support - APIs at /api/threads/* and /api/templates/* - Thread scheduling with reply chain support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
446
dashboard/templates/analytics.html
Normal file
446
dashboard/templates/analytics.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Analytics - Social Media Automation</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body { background-color: #1a1a2e; color: #eee; }
|
||||
.card { background-color: #16213e; border-radius: 12px; }
|
||||
.accent { color: #d4a574; }
|
||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||
.btn-primary:hover { background-color: #c49564; }
|
||||
.btn-secondary { background-color: #374151; color: #fff; }
|
||||
.btn-secondary:hover { background-color: #4b5563; }
|
||||
.stat-up { color: #10b981; }
|
||||
.stat-down { color: #ef4444; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="accent">Consultoría AS</span> - Analytics
|
||||
</h1>
|
||||
<nav class="flex gap-4">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
|
||||
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
|
||||
<a href="/dashboard/analytics" class="px-4 py-2 rounded bg-gray-800">Analytics</a>
|
||||
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
|
||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Period Selector -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Dashboard de Analytics</h2>
|
||||
<div class="flex gap-2">
|
||||
<select id="periodSelect" onchange="loadDashboard()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="7">Últimos 7 días</option>
|
||||
<option value="30" selected>Últimos 30 días</option>
|
||||
<option value="90">Últimos 90 días</option>
|
||||
</select>
|
||||
<select id="platformSelect" onchange="loadDashboard()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option value="x">X (Twitter)</option>
|
||||
<option value="threads">Threads</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
</select>
|
||||
<button onclick="generateReport()" class="btn-primary px-4 py-2 rounded">
|
||||
Generar Reporte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold accent" id="totalPosts">-</div>
|
||||
<div class="text-gray-400 text-sm">Posts Publicados</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-400" id="totalImpressions">-</div>
|
||||
<div class="text-gray-400 text-sm">Impresiones</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-400" id="totalEngagements">-</div>
|
||||
<div class="text-gray-400 text-sm">Interacciones</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-purple-400" id="engagementRate">-</div>
|
||||
<div class="text-gray-400 text-sm">Engagement Rate</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-400" id="pendingInteractions">-</div>
|
||||
<div class="text-gray-400 text-sm">Por Responder</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engagement Breakdown -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-8">
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Likes</div>
|
||||
<div class="text-2xl font-bold" id="totalLikes">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">❤️</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Comentarios</div>
|
||||
<div class="text-2xl font-bold" id="totalComments">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">💬</div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-gray-400 text-sm">Compartidos</div>
|
||||
<div class="text-2xl font-bold" id="totalShares">-</div>
|
||||
</div>
|
||||
<div class="text-4xl">🔄</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<!-- Engagement Trend Chart -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Tendencia de Engagement</h3>
|
||||
<canvas id="engagementChart" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Platform Breakdown -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Por Plataforma</h3>
|
||||
<div id="platformBreakdown" class="space-y-3">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 mb-8">
|
||||
<!-- Top Posts -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Top Posts por Engagement</h3>
|
||||
<div id="topPosts" class="space-y-3">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optimal Times Heatmap -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">Mejores Horarios</h3>
|
||||
<div id="optimalTimes" class="space-y-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Type Performance -->
|
||||
<div class="card p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
|
||||
<div id="contentBreakdown" class="grid grid-cols-4 gap-4">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reports History -->
|
||||
<div class="card p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Reportes Anteriores</h3>
|
||||
<button onclick="sendReportTelegram()" class="btn-secondary px-4 py-2 rounded text-sm">
|
||||
Enviar a Telegram
|
||||
</button>
|
||||
</div>
|
||||
<div id="reportsList" class="space-y-2">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
let engagementChart = null;
|
||||
|
||||
async function loadDashboard() {
|
||||
const days = document.getElementById('periodSelect').value;
|
||||
const platform = document.getElementById('platformSelect').value;
|
||||
|
||||
try {
|
||||
// Load dashboard stats
|
||||
const params = new URLSearchParams({ days });
|
||||
if (platform) params.append('platform', platform);
|
||||
|
||||
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
|
||||
const stats = await statsRes.json();
|
||||
|
||||
// Update stat cards
|
||||
document.getElementById('totalPosts').textContent = stats.total_posts;
|
||||
document.getElementById('totalImpressions').textContent = formatNumber(stats.total_impressions);
|
||||
document.getElementById('totalEngagements').textContent = formatNumber(stats.total_engagements);
|
||||
document.getElementById('engagementRate').textContent = stats.avg_engagement_rate + '%';
|
||||
document.getElementById('pendingInteractions').textContent = stats.pending_interactions;
|
||||
document.getElementById('totalLikes').textContent = formatNumber(stats.total_likes);
|
||||
document.getElementById('totalComments').textContent = formatNumber(stats.total_comments);
|
||||
document.getElementById('totalShares').textContent = formatNumber(stats.total_shares);
|
||||
|
||||
// Platform breakdown
|
||||
renderPlatformBreakdown(stats.platform_breakdown);
|
||||
|
||||
// Content breakdown
|
||||
renderContentBreakdown(stats.content_breakdown);
|
||||
|
||||
// Load engagement trend
|
||||
const trendRes = await fetch(`/api/analytics/engagement-trend?${params}`);
|
||||
const trendData = await trendRes.json();
|
||||
renderEngagementChart(trendData.trend);
|
||||
|
||||
// Load top posts
|
||||
const topRes = await fetch(`/api/analytics/top-posts?${params}&limit=5`);
|
||||
const topData = await topRes.json();
|
||||
renderTopPosts(topData.posts);
|
||||
|
||||
// Load optimal times
|
||||
const timesRes = await fetch(`/api/analytics/optimal-times?days=${days}${platform ? '&platform=' + platform : ''}`);
|
||||
const timesData = await timesRes.json();
|
||||
renderOptimalTimes(timesData.optimal_times);
|
||||
|
||||
// Load reports
|
||||
const reportsRes = await fetch('/api/analytics/reports?limit=5');
|
||||
const reportsData = await reportsRes.json();
|
||||
renderReports(reportsData.reports);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function renderPlatformBreakdown(breakdown) {
|
||||
const container = document.getElementById('platformBreakdown');
|
||||
container.innerHTML = '';
|
||||
|
||||
const platformIcons = {
|
||||
'x': '𝕏',
|
||||
'threads': '🧵',
|
||||
'instagram': '📷',
|
||||
'facebook': '📘'
|
||||
};
|
||||
|
||||
for (const [platform, data] of Object.entries(breakdown)) {
|
||||
container.innerHTML += `
|
||||
<div class="flex items-center justify-between bg-gray-800 rounded p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">${platformIcons[platform] || '📱'}</span>
|
||||
<div>
|
||||
<div class="font-medium capitalize">${platform}</div>
|
||||
<div class="text-sm text-gray-400">${data.posts} posts</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-green-400 font-bold">${formatNumber(data.engagements)}</div>
|
||||
<div class="text-sm text-gray-400">interacciones</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (Object.keys(breakdown).length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay datos disponibles</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderContentBreakdown(breakdown) {
|
||||
const container = document.getElementById('contentBreakdown');
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [type, data] of Object.entries(breakdown)) {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-4 text-center">
|
||||
<div class="text-lg font-bold accent">${data.posts}</div>
|
||||
<div class="text-sm text-gray-400 capitalize">${type.replace('_', ' ')}</div>
|
||||
<div class="text-xs text-green-400 mt-1">${formatNumber(data.engagements)} eng.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (Object.keys(breakdown).length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 col-span-4">No hay datos disponibles</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEngagementChart(trend) {
|
||||
const ctx = document.getElementById('engagementChart').getContext('2d');
|
||||
|
||||
if (engagementChart) {
|
||||
engagementChart.destroy();
|
||||
}
|
||||
|
||||
engagementChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trend.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Impresiones',
|
||||
data: trend.map(d => d.impressions),
|
||||
borderColor: '#60a5fa',
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Interacciones',
|
||||
data: trend.map(d => d.engagements),
|
||||
borderColor: '#34d399',
|
||||
backgroundColor: 'rgba(52, 211, 153, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: { color: '#9ca3af' }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: '#374151' }
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#9ca3af' },
|
||||
grid: { color: '#374151' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopPosts(posts) {
|
||||
const container = document.getElementById('topPosts');
|
||||
container.innerHTML = '';
|
||||
|
||||
posts.forEach((post, i) => {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-3">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span class="text-accent font-bold">#${i + 1}</span>
|
||||
<span class="text-green-400 text-sm">${post.engagement_rate}% eng.</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 mb-2">${post.content}</p>
|
||||
<div class="flex gap-4 text-xs text-gray-500">
|
||||
<span>❤️ ${post.likes}</span>
|
||||
<span>💬 ${post.comments}</span>
|
||||
<span>🔄 ${post.shares}</span>
|
||||
<span class="ml-auto">${post.platforms.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay posts con métricas</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOptimalTimes(times) {
|
||||
const container = document.getElementById('optimalTimes');
|
||||
container.innerHTML = '';
|
||||
|
||||
// Show top 10 times
|
||||
times.slice(0, 10).forEach(time => {
|
||||
const barWidth = Math.min(time.avg_engagement_rate * 10, 100);
|
||||
container.innerHTML += `
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-20 text-sm text-gray-400">${time.day_name} ${time.hour_formatted}</span>
|
||||
<div class="flex-1 bg-gray-800 rounded h-4 overflow-hidden">
|
||||
<div class="bg-accent h-full" style="width: ${barWidth}%"></div>
|
||||
</div>
|
||||
<span class="text-sm text-green-400 w-16 text-right">${time.avg_engagement_rate}%</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (times.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay suficientes datos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReports(reports) {
|
||||
const container = document.getElementById('reportsList');
|
||||
container.innerHTML = '';
|
||||
|
||||
reports.forEach(report => {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded p-3 flex justify-between items-center">
|
||||
<div>
|
||||
<span class="font-medium capitalize">${report.report_type}</span>
|
||||
<span class="text-gray-500 text-sm ml-2">
|
||||
${report.period_start} - ${report.period_end}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<span>${report.total_posts} posts</span>
|
||||
<span class="text-green-400">${report.avg_engagement_rate}% eng.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (reports.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No hay reportes generados</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
try {
|
||||
const res = await fetch('/api/analytics/reports/generate?report_type=weekly', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('Reporte generado exitosamente');
|
||||
loadDashboard();
|
||||
} else {
|
||||
alert('Error: ' + data.detail);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error generando reporte');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReportTelegram() {
|
||||
try {
|
||||
const res = await fetch('/api/analytics/reports/send-telegram', {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
alert(data.message);
|
||||
} catch (error) {
|
||||
alert('Error enviando reporte');
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page load
|
||||
document.addEventListener('DOMContentLoaded', loadDashboard);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
557
dashboard/templates/leads.html
Normal file
557
dashboard/templates/leads.html
Normal file
@@ -0,0 +1,557 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Leads - Social Media Automation</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #1a1a2e; color: #eee; }
|
||||
.card { background-color: #16213e; border-radius: 12px; }
|
||||
.accent { color: #d4a574; }
|
||||
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
|
||||
.btn-primary:hover { background-color: #c49564; }
|
||||
.btn-secondary { background-color: #374151; color: #fff; }
|
||||
.btn-secondary:hover { background-color: #4b5563; }
|
||||
.status-new { background-color: #3b82f6; }
|
||||
.status-contacted { background-color: #8b5cf6; }
|
||||
.status-qualified { background-color: #f59e0b; }
|
||||
.status-proposal { background-color: #ec4899; }
|
||||
.status-won { background-color: #10b981; }
|
||||
.status-lost { background-color: #ef4444; }
|
||||
.priority-urgent { border-left: 4px solid #ef4444; }
|
||||
.priority-high { border-left: 4px solid #f59e0b; }
|
||||
.priority-medium { border-left: 4px solid #3b82f6; }
|
||||
.priority-low { border-left: 4px solid #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span class="accent">Consultoría AS</span> - Leads
|
||||
</h1>
|
||||
<nav class="flex gap-4">
|
||||
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
|
||||
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
|
||||
<a href="/dashboard/leads" class="px-4 py-2 rounded bg-gray-800">Leads</a>
|
||||
<a href="/dashboard/analytics" class="px-4 py-2 rounded hover:bg-gray-800">Analytics</a>
|
||||
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-8">
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold accent" id="totalLeads">-</div>
|
||||
<div class="text-gray-400 text-sm">Total Leads</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-400" id="newLeads">-</div>
|
||||
<div class="text-gray-400 text-sm">Nuevos</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-purple-400" id="contactedLeads">-</div>
|
||||
<div class="text-gray-400 text-sm">Contactados</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-400" id="qualifiedLeads">-</div>
|
||||
<div class="text-gray-400 text-sm">Calificados</div>
|
||||
</div>
|
||||
<div class="card p-4 text-center">
|
||||
<div class="text-3xl font-bold text-yellow-400" id="unsyncedLeads">-</div>
|
||||
<div class="text-gray-400 text-sm">Sin Sincronizar</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Actions -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex gap-2">
|
||||
<select id="statusFilter" onchange="loadLeads()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="new">Nuevo</option>
|
||||
<option value="contacted">Contactado</option>
|
||||
<option value="qualified">Calificado</option>
|
||||
<option value="proposal">Propuesta</option>
|
||||
<option value="won">Ganado</option>
|
||||
<option value="lost">Perdido</option>
|
||||
</select>
|
||||
<select id="priorityFilter" onchange="loadLeads()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="">Todas las prioridades</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="low">Baja</option>
|
||||
</select>
|
||||
<select id="platformFilter" onchange="loadLeads()"
|
||||
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option value="x">X (Twitter)</option>
|
||||
<option value="threads">Threads</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showNewLeadModal()" class="btn-primary px-4 py-2 rounded">
|
||||
+ Nuevo Lead
|
||||
</button>
|
||||
<button onclick="syncLeadsToOdoo()" class="btn-secondary px-4 py-2 rounded">
|
||||
Sincronizar a Odoo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leads List -->
|
||||
<div class="card p-6">
|
||||
<div id="leadsList" class="space-y-4">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-center mt-6 gap-2">
|
||||
<button onclick="prevPage()" id="prevBtn" class="btn-secondary px-4 py-2 rounded" disabled>
|
||||
Anterior
|
||||
</button>
|
||||
<span id="pageInfo" class="px-4 py-2">Página 1</span>
|
||||
<button onclick="nextPage()" id="nextBtn" class="btn-secondary px-4 py-2 rounded">
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- New Lead Modal -->
|
||||
<div id="newLeadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center z-50">
|
||||
<div class="card p-6 w-full max-w-lg">
|
||||
<h3 class="text-xl font-semibold mb-4">Nuevo Lead</h3>
|
||||
<form id="newLeadForm" onsubmit="createLead(event)">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Nombre</label>
|
||||
<input type="text" name="name" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input type="email" name="email" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Teléfono</label>
|
||||
<input type="text" name="phone" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Empresa</label>
|
||||
<input type="text" name="company" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Plataforma *</label>
|
||||
<select name="platform" required class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
<option value="manual">Manual</option>
|
||||
<option value="x">X (Twitter)</option>
|
||||
<option value="threads">Threads</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Prioridad</label>
|
||||
<select name="priority" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
<option value="medium">Media</option>
|
||||
<option value="low">Baja</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-400 mb-1">Interés</label>
|
||||
<textarea name="interest" rows="2" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-400 mb-1">Notas</label>
|
||||
<textarea name="notes" rows="2" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="closeNewLeadModal()" class="btn-secondary px-4 py-2 rounded">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">
|
||||
Crear Lead
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Lead Modal -->
|
||||
<div id="editLeadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center z-50">
|
||||
<div class="card p-6 w-full max-w-lg">
|
||||
<h3 class="text-xl font-semibold mb-4">Editar Lead</h3>
|
||||
<form id="editLeadForm" onsubmit="updateLead(event)">
|
||||
<input type="hidden" name="lead_id" id="editLeadId">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Nombre</label>
|
||||
<input type="text" name="name" id="editName" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Email</label>
|
||||
<input type="email" name="email" id="editEmail" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Teléfono</label>
|
||||
<input type="text" name="phone" id="editPhone" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Empresa</label>
|
||||
<input type="text" name="company" id="editCompany" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Estado</label>
|
||||
<select name="status" id="editStatus" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
<option value="new">Nuevo</option>
|
||||
<option value="contacted">Contactado</option>
|
||||
<option value="qualified">Calificado</option>
|
||||
<option value="proposal">Propuesta</option>
|
||||
<option value="won">Ganado</option>
|
||||
<option value="lost">Perdido</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-1">Prioridad</label>
|
||||
<select name="priority" id="editPriority" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2">
|
||||
<option value="low">Baja</option>
|
||||
<option value="medium">Media</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-400 mb-1">Interés</label>
|
||||
<textarea name="interest" id="editInterest" rows="2" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm text-gray-400 mb-1">Notas</label>
|
||||
<textarea name="notes" id="editNotes" rows="2" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick="closeEditLeadModal()" class="btn-secondary px-4 py-2 rounded">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn-primary px-4 py-2 rounded">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPage = 0;
|
||||
const pageSize = 20;
|
||||
let totalLeads = 0;
|
||||
|
||||
async function loadLeads() {
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const priority = document.getElementById('priorityFilter').value;
|
||||
const platform = document.getElementById('platformFilter').value;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: pageSize,
|
||||
offset: currentPage * pageSize
|
||||
});
|
||||
|
||||
if (status) params.append('status', status);
|
||||
if (priority) params.append('priority', priority);
|
||||
if (platform) params.append('platform', platform);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/leads/?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
totalLeads = data.total;
|
||||
renderLeads(data.leads);
|
||||
updatePagination();
|
||||
|
||||
// Also load stats
|
||||
loadStats();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading leads:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/leads/stats/summary');
|
||||
const stats = await res.json();
|
||||
|
||||
document.getElementById('totalLeads').textContent = stats.total;
|
||||
document.getElementById('newLeads').textContent = stats.by_status.new || 0;
|
||||
document.getElementById('contactedLeads').textContent = stats.by_status.contacted || 0;
|
||||
document.getElementById('qualifiedLeads').textContent = stats.by_status.qualified || 0;
|
||||
document.getElementById('unsyncedLeads').textContent = stats.unsynced_to_odoo || 0;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeads(leads) {
|
||||
const container = document.getElementById('leadsList');
|
||||
container.innerHTML = '';
|
||||
|
||||
const statusLabels = {
|
||||
new: 'Nuevo',
|
||||
contacted: 'Contactado',
|
||||
qualified: 'Calificado',
|
||||
proposal: 'Propuesta',
|
||||
won: 'Ganado',
|
||||
lost: 'Perdido'
|
||||
};
|
||||
|
||||
const platformIcons = {
|
||||
x: '𝕏',
|
||||
threads: '🧵',
|
||||
instagram: '📷',
|
||||
facebook: '📘',
|
||||
manual: '✏️'
|
||||
};
|
||||
|
||||
leads.forEach(lead => {
|
||||
container.innerHTML += `
|
||||
<div class="bg-gray-800 rounded-lg p-4 priority-${lead.priority}">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">${platformIcons[lead.platform] || '📱'}</span>
|
||||
<div>
|
||||
<h4 class="font-semibold">${lead.name || lead.username || 'Sin nombre'}</h4>
|
||||
<p class="text-sm text-gray-400">
|
||||
${lead.email || ''} ${lead.phone ? '| ' + lead.phone : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="status-${lead.status} px-2 py-1 rounded text-xs text-white">
|
||||
${statusLabels[lead.status] || lead.status}
|
||||
</span>
|
||||
${lead.synced_to_odoo ? '<span class="text-green-400 text-xs">✓ Odoo</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${lead.interest ? `<p class="text-sm text-gray-300 mb-2">${lead.interest}</p>` : ''}
|
||||
${lead.company ? `<p class="text-xs text-gray-500 mb-2">Empresa: ${lead.company}</p>` : ''}
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs text-gray-500">
|
||||
${new Date(lead.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editLead(${lead.id})" class="text-sm text-blue-400 hover:underline">
|
||||
Editar
|
||||
</button>
|
||||
${!lead.synced_to_odoo ? `
|
||||
<button onclick="syncSingleLead(${lead.id})" class="text-sm text-accent hover:underline">
|
||||
Sincronizar
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="deleteLead(${lead.id})" class="text-sm text-red-400 hover:underline">
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
if (leads.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 text-center py-8">No hay leads que coincidan con los filtros</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function updatePagination() {
|
||||
const totalPages = Math.ceil(totalLeads / pageSize);
|
||||
document.getElementById('pageInfo').textContent = `Página ${currentPage + 1} de ${totalPages || 1}`;
|
||||
document.getElementById('prevBtn').disabled = currentPage === 0;
|
||||
document.getElementById('nextBtn').disabled = currentPage >= totalPages - 1;
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
loadLeads();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
const totalPages = Math.ceil(totalLeads / pageSize);
|
||||
if (currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
loadLeads();
|
||||
}
|
||||
}
|
||||
|
||||
function showNewLeadModal() {
|
||||
document.getElementById('newLeadModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeNewLeadModal() {
|
||||
document.getElementById('newLeadModal').classList.add('hidden');
|
||||
document.getElementById('newLeadForm').reset();
|
||||
}
|
||||
|
||||
async function createLead(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const leadData = {
|
||||
name: formData.get('name') || null,
|
||||
email: formData.get('email') || null,
|
||||
phone: formData.get('phone') || null,
|
||||
company: formData.get('company') || null,
|
||||
platform: formData.get('platform'),
|
||||
priority: formData.get('priority'),
|
||||
interest: formData.get('interest') || null,
|
||||
notes: formData.get('notes') || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/leads/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(leadData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeNewLeadModal();
|
||||
loadLeads();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert('Error: ' + (data.detail || 'Error creando lead'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error creando lead');
|
||||
}
|
||||
}
|
||||
|
||||
async function editLead(leadId) {
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${leadId}`);
|
||||
const lead = await res.json();
|
||||
|
||||
document.getElementById('editLeadId').value = lead.id;
|
||||
document.getElementById('editName').value = lead.name || '';
|
||||
document.getElementById('editEmail').value = lead.email || '';
|
||||
document.getElementById('editPhone').value = lead.phone || '';
|
||||
document.getElementById('editCompany').value = lead.company || '';
|
||||
document.getElementById('editStatus').value = lead.status;
|
||||
document.getElementById('editPriority').value = lead.priority;
|
||||
document.getElementById('editInterest').value = lead.interest || '';
|
||||
document.getElementById('editNotes').value = lead.notes || '';
|
||||
|
||||
document.getElementById('editLeadModal').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
alert('Error cargando lead');
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditLeadModal() {
|
||||
document.getElementById('editLeadModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function updateLead(event) {
|
||||
event.preventDefault();
|
||||
const leadId = document.getElementById('editLeadId').value;
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const leadData = {
|
||||
name: formData.get('name') || null,
|
||||
email: formData.get('email') || null,
|
||||
phone: formData.get('phone') || null,
|
||||
company: formData.get('company') || null,
|
||||
status: formData.get('status'),
|
||||
priority: formData.get('priority'),
|
||||
interest: formData.get('interest') || null,
|
||||
notes: formData.get('notes') || null
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${leadId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(leadData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeEditLeadModal();
|
||||
loadLeads();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert('Error: ' + (data.detail || 'Error actualizando lead'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error actualizando lead');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteLead(leadId) {
|
||||
if (!confirm('¿Estás seguro de eliminar este lead?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${leadId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
loadLeads();
|
||||
} else {
|
||||
alert('Error eliminando lead');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error eliminando lead');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSingleLead(leadId) {
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${leadId}/sync-odoo`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert('Lead sincronizado exitosamente');
|
||||
loadLeads();
|
||||
} else {
|
||||
alert('Error: ' + (data.detail || 'Error sincronizando'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error sincronizando lead');
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLeadsToOdoo() {
|
||||
try {
|
||||
const res = await fetch('/api/odoo/sync/leads', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
alert(`Sincronización completada: ${data.created} leads exportados`);
|
||||
loadLeads();
|
||||
} else {
|
||||
alert('Error: ' + (data.detail || 'Error sincronizando'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error sincronizando leads');
|
||||
}
|
||||
}
|
||||
|
||||
// Load on page load
|
||||
document.addEventListener('DOMContentLoaded', loadLeads);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user