- Add analytics tables (analytics_events, analytics_daily) - Add /api/admin/leads endpoint for lead listing - Add /api/admin/analytics/overview and /api/admin/analytics/charts endpoints - Seed database with 15 leads and 30 days of analytics data - Update dashboard.html with: - Animated counters for stats - Performance chart (views/leads over 6 months) - Leads status pie chart - Property types bar chart - Traffic sources doughnut chart - Top properties horizontal bar chart - Recent properties table with images - Recent leads list with status badges - Add API methods: getAnalyticsOverview(), getAnalyticsCharts()
548 lines
19 KiB
HTML
548 lines
19 KiB
HTML
<!-- Dashboard Section -->
|
|
<div class="section active" id="section-dashboard">
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title">Dashboard</h1>
|
|
<p class="page-subtitle">Resumen general de la plataforma</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="admin.exportStats()">
|
|
<i class="bi bi-download me-2"></i>Exportar
|
|
</button>
|
|
<button class="btn btn-primary" onclick="admin.showPropertyModal()">
|
|
<i class="bi bi-plus-lg me-2"></i>Nueva Propiedad
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Grid -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<div class="stat-card-icon green">
|
|
<i class="bi bi-building"></i>
|
|
</div>
|
|
<div class="stat-card-trend up" id="propertiesTrend">
|
|
<i class="bi bi-arrow-up"></i>
|
|
<span>12%</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-value" id="statProperties">0</div>
|
|
<div class="stat-card-label">Propiedades Activas</div>
|
|
<div class="stat-card-sub">+<span id="propertiesMonth">3</span> este mes</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<div class="stat-card-icon blue">
|
|
<i class="bi bi-people"></i>
|
|
</div>
|
|
<div class="stat-card-trend up" id="leadsTrend">
|
|
<i class="bi bi-arrow-up"></i>
|
|
<span>24%</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-value" id="statLeads">0</div>
|
|
<div class="stat-card-label">Nuevos Leads</div>
|
|
<div class="stat-card-sub"><span id="totalLeads">0</span> totales</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<div class="stat-card-icon orange">
|
|
<i class="bi bi-eye"></i>
|
|
</div>
|
|
<div class="stat-card-trend" id="viewsTrend">
|
|
<i class="bi bi-arrow-up"></i>
|
|
<span>8%</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-value" id="statViews">0</div>
|
|
<div class="stat-card-label">Vistas Totales</div>
|
|
<div class="stat-card-sub"><span id="viewsMonth">0</span> este mes</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<div class="stat-card-icon red">
|
|
<i class="bi bi-currency-euro"></i>
|
|
</div>
|
|
<div class="stat-card-trend up" id="priceTrend">
|
|
<i class="bi bi-arrow-up"></i>
|
|
<span>5%</span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card-value" id="statAvgPrice">€0</div>
|
|
<div class="stat-card-label">Precio Promedio</div>
|
|
<div class="stat-card-sub">€<span id="pricePerM2">0</span>/m²</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts Row -->
|
|
<div class="row mt-4">
|
|
<div class="col-lg-8">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0">Rendimiento</h5>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary active" data-period="week">Semana</button>
|
|
<button class="btn btn-outline-primary" data-period="month">Mes</button>
|
|
<button class="btn btn-outline-primary" data-period="year">Año</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="performanceChart" height="300"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Estado de Leads</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="leadsStatusChart" height="300"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secondary Charts Row -->
|
|
<div class="row mt-4">
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Tipos de Propiedades</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="typesChart" height="250"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Fuente de Tráfico</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="trafficChart" height="250"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">Propiedades Top</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="topPropertiesChart" height="250"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Section -->
|
|
<div class="row mt-4">
|
|
<div class="col-lg-8">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0">Propiedades Recientes</h5>
|
|
<a href="#properties" class="btn btn-sm btn-outline-primary" onclick="admin.navigateTo('properties')">Ver todas</a>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>Propiedad</th>
|
|
<th>Tipo</th>
|
|
<th>Ubicación</th>
|
|
<th>Precio</th>
|
|
<th>Estado</th>
|
|
<th>Vistas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="recentPropertiesTable">
|
|
<tr>
|
|
<td colspan="6" class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Cargando...</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0">Leads Recientes</h5>
|
|
<a href="#leads" class="btn btn-sm btn-outline-primary" onclick="admin.navigateTo('leads')">Ver todos</a>
|
|
</div>
|
|
<div class="card-body" id="recentLeadsList">
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">Cargando...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Dashboard Charts
|
|
let dashboardCharts = {};
|
|
|
|
async function loadDashboard() {
|
|
try {
|
|
// Load stats
|
|
const statsRes = await API.getAdminStats();
|
|
if (statsRes.success) {
|
|
updateStatCards(statsRes.data);
|
|
}
|
|
|
|
// Load analytics for charts
|
|
const [chartsRes, leadsRes, propertiesRes] = await Promise.all([
|
|
fetch('/api/admin/analytics/charts').then(r => r.json()),
|
|
API.getLeads({ limit: 5 }),
|
|
API.getProperties({ limit: 5 })
|
|
]);
|
|
|
|
if (chartsRes.success) {
|
|
updateCharts(chartsRes.data);
|
|
}
|
|
|
|
if (leadsRes.success) {
|
|
updateLeadsList(leadsRes.data);
|
|
}
|
|
|
|
if (propertiesRes.success) {
|
|
updatePropertiesTable(propertiesRes.data);
|
|
}
|
|
|
|
} catch (e) {
|
|
console.error('Failed to load dashboard:', e);
|
|
}
|
|
}
|
|
|
|
function updateStatCards(stats) {
|
|
// Animate counters
|
|
animateCounter('statProperties', stats.properties.active);
|
|
animateCounter('statLeads', stats.leads.new);
|
|
animateCounter('statViews', stats.analytics.views);
|
|
|
|
const priceFormatter = new Intl.NumberFormat('es-ES', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0
|
|
});
|
|
|
|
document.getElementById('statAvgPrice').textContent = priceFormatter.format(stats.averages.price);
|
|
document.getElementById('totalLeads').textContent = stats.leads.total;
|
|
document.getElementById('pricePerM2').textContent = stats.averages.pricePerM2.toLocaleString();
|
|
|
|
// Update lead count badge
|
|
if (document.getElementById('newLeadsBadge')) {
|
|
document.getElementById('newLeadsBadge').textContent = stats.leads.new;
|
|
}
|
|
}
|
|
|
|
function animateCounter(id, target) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
|
|
const duration = 1000;
|
|
const start = 0;
|
|
const startTime = performance.now();
|
|
|
|
function update(currentTime) {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
const current = Math.floor(start + (target - start) * eased);
|
|
|
|
el.textContent = current.toLocaleString();
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(update);
|
|
}
|
|
}
|
|
|
|
requestAnimationFrame(update);
|
|
}
|
|
|
|
function updateCharts(data) {
|
|
// Performance Chart (Views & Leads)
|
|
const performanceCtx = document.getElementById('performanceChart')?.getContext('2d');
|
|
if (performanceCtx) {
|
|
if (dashboardCharts.performance) dashboardCharts.performance.destroy();
|
|
|
|
dashboardCharts.performance = new Chart(performanceCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.months,
|
|
datasets: [
|
|
{
|
|
label: 'Vistas',
|
|
data: data.viewsPerMonth,
|
|
borderColor: '#1a5f4a',
|
|
backgroundColor: 'rgba(26, 95, 74, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
},
|
|
{
|
|
label: 'Leads',
|
|
data: data.leadsPerMonth,
|
|
borderColor: '#d4a853',
|
|
backgroundColor: 'rgba(212, 168, 83, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Leads Status Chart
|
|
const leadsCtx = document.getElementById('leadsStatusChart')?.getContext('2d');
|
|
if (leadsCtx && data.leadsStatus) {
|
|
if (dashboardCharts.leadsStatus) dashboardCharts.leadsStatus.destroy();
|
|
|
|
const statusColors = {
|
|
'new': '#3b82f6',
|
|
'contacted': '#f59e0b',
|
|
'qualified': '#10b981',
|
|
'negotiating': '#8b5cf6',
|
|
'closed': '#1a5f4a'
|
|
};
|
|
|
|
const statusLabels = {
|
|
'new': 'Nuevo',
|
|
'contacted': 'Contactado',
|
|
'qualified': 'Calificado',
|
|
'negotiating': 'Negociando',
|
|
'closed': 'Cerrado'
|
|
};
|
|
|
|
dashboardCharts.leadsStatus = new Chart(leadsCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.leadsStatus.map(l => statusLabels[l.status] || l.status),
|
|
datasets: [{
|
|
data: data.leadsStatus.map(l => l.count),
|
|
backgroundColor: data.leadsStatus.map(l => statusColors[l.status] || '#6c757d')
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Types Chart
|
|
const typesCtx = document.getElementById('typesChart')?.getContext('2d');
|
|
if (typesCtx && data.propertiesByCity) {
|
|
if (dashboardCharts.types) dashboardCharts.types.destroy();
|
|
|
|
dashboardCharts.types = new Chart(typesCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.propertiesByCity.slice(0, 5).map(c => c.city),
|
|
datasets: [{
|
|
label: 'Propiedades',
|
|
data: data.propertiesByCity.slice(0, 5).map(c => c.count),
|
|
backgroundColor: ['#1a5f4a', '#d4a853', '#e85d04', '#3b82f6', '#10b981']
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Traffic Chart
|
|
const trafficCtx = document.getElementById('trafficChart')?.getContext('2d');
|
|
if (trafficCtx) {
|
|
if (dashboardCharts.traffic) dashboardCharts.traffic.destroy();
|
|
|
|
const trafficSources = [
|
|
{ source: 'Directo', count: 35 },
|
|
{ source: 'Búsqueda', count: 30 },
|
|
{ source: 'Social', count: 20 },
|
|
{ source: 'Referido', count: 10 },
|
|
{ source: 'Email', count: 5 }
|
|
];
|
|
|
|
dashboardCharts.traffic = new Chart(trafficCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: trafficSources.map(s => s.source),
|
|
datasets: [{
|
|
data: trafficSources.map(s => s.count),
|
|
backgroundColor: ['#1a5f4a', '#d4a853', '#e85d04', '#3b82f6', '#6c757d']
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Top Properties Chart
|
|
const topCtx = document.getElementById('topPropertiesChart')?.getContext('2d');
|
|
if (topCtx && data.topProperties) {
|
|
if (dashboardCharts.top) dashboardCharts.top.destroy();
|
|
|
|
dashboardCharts.top = new Chart(topCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.topProperties.map(p => p.reference),
|
|
datasets: [{
|
|
label: 'Vistas',
|
|
data: data.topProperties.map(p => p.views_count || 0),
|
|
backgroundColor: '#d4a853'
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function updatePropertiesTable(properties) {
|
|
const tbody = document.getElementById('recentPropertiesTable');
|
|
if (!tbody) return;
|
|
|
|
const typeLabels = {
|
|
'urban': 'Urbano',
|
|
'agricultural': 'Agrícola',
|
|
'house': 'Casa',
|
|
'apartment': 'Apartamento'
|
|
};
|
|
|
|
const statusColors = {
|
|
'active': 'success',
|
|
'reserved': 'warning',
|
|
'sold': 'secondary'
|
|
};
|
|
|
|
tbody.innerHTML = properties.map(p => {
|
|
const images = p.images ? JSON.parse(p.images) : [];
|
|
const image = images[0] || 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=100&q=80';
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<img src="${image}" class="rounded me-2" style="width:40px;height:40px;object-fit:cover" alt="">
|
|
<div>
|
|
<div class="fw-medium">${p.title_es?.substring(0, 30) || 'Sin título'}...</div>
|
|
<small class="text-muted">${p.reference}</small>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td><span class="badge bg-light text-dark">${typeLabels[p.type] || p.type}</span></td>
|
|
<td>${p.city}</td>
|
|
<td>€${p.price?.toLocaleString()}</td>
|
|
<td><span class="badge bg-${statusColors[p.status] || 'secondary'}">${p.status}</span></td>
|
|
<td>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<i class="bi bi-eye text-muted"></i>
|
|
<span>${p.views_count || 0}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateLeadsList(leads) {
|
|
const container = document.getElementById('recentLeadsList');
|
|
if (!container) return;
|
|
|
|
const statusColors = {
|
|
'new': 'danger',
|
|
'contacted': 'warning',
|
|
'qualified': 'info',
|
|
'negotiating': 'primary',
|
|
'closed': 'success'
|
|
};
|
|
|
|
const statusLabels = {
|
|
'new': 'Nuevo',
|
|
'contacted': 'Contactado',
|
|
'qualified': 'Calificado',
|
|
'negotiating': 'Negociando',
|
|
'closed': 'Cerrado'
|
|
};
|
|
|
|
container.innerHTML = leads.map(l => `
|
|
<div class="d-flex align-items-center gap-3 mb-3 pb-3 border-bottom">
|
|
<div class="avatar bg-primary text-white rounded-circle d-flex align-items-center justify-content-center" style="width:40px;height:40px">
|
|
${l.name?.charAt(0) || '?'}
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="fw-medium">${l.name}</div>
|
|
<small class="text-muted">${l.email}</small>
|
|
</div>
|
|
<span class="badge bg-${statusColors[l.status] || 'secondary'}">${statusLabels[l.status] || l.status}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Initialize dashboard when loaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Check if Chart.js is loaded
|
|
if (typeof Chart === 'undefined') {
|
|
console.warn('Chart.js not loaded, charts will not render');
|
|
}
|
|
}); |