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:
2026-01-28 03:10:42 +00:00
parent 03b5f9f2e2
commit ecc2ca73ea
31 changed files with 6067 additions and 6 deletions

View 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>

View 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>