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>
447 lines
19 KiB
HTML
447 lines
19 KiB
HTML
<!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>
|