Files
TenerifeProp/public/admin/dashboard.html
TenerifeProp Dev 343a9381e1 feat: add real data to admin dashboard with charts
- 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()
2026-04-06 10:54:26 +01:00

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');
}
});