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()
This commit is contained in:
TenerifeProp Dev
2026-04-06 10:54:26 +01:00
parent f6e26cffe3
commit 343a9381e1
3 changed files with 652 additions and 65 deletions

View File

@@ -6,10 +6,10 @@
<p class="page-subtitle">Resumen general de la plataforma</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary">
<button class="btn btn-outline-secondary" onclick="admin.exportStats()">
<i class="bi bi-download me-2"></i>Exportar
</button>
<button class="btn btn-primary">
<button class="btn btn-primary" onclick="admin.showPropertyModal()">
<i class="bi bi-plus-lg me-2"></i>Nueva Propiedad
</button>
</div>
@@ -22,13 +22,14 @@
<div class="stat-card-icon green">
<i class="bi bi-building"></i>
</div>
<div class="stat-card-trend up">
<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">
@@ -36,13 +37,14 @@
<div class="stat-card-icon blue">
<i class="bi bi-people"></i>
</div>
<div class="stat-card-trend up">
<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">
@@ -50,13 +52,14 @@
<div class="stat-card-icon orange">
<i class="bi bi-eye"></i>
</div>
<div class="stat-card-trend down">
<i class="bi bi-arrow-down"></i>
<span>5%</span>
<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">
@@ -64,26 +67,94 @@
<div class="stat-card-icon red">
<i class="bi bi-currency-euro"></i>
</div>
<div class="stat-card-trend up">
<div class="stat-card-trend up" id="priceTrend">
<i class="bi bi-arrow-up"></i>
<span>8%</span>
<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">
<div class="row mt-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<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 class="table table-hover">
<thead>
<tr>
<th>Propiedad</th>
@@ -91,11 +162,12 @@
<th>Ubicación</th>
<th>Precio</th>
<th>Estado</th>
<th>Vistas</th>
</tr>
</thead>
<tbody id="recentPropertiesTable">
<tr>
<td colspan="5" class="text-center py-4">
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Cargando...</span>
</div>
@@ -110,8 +182,9 @@
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<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">
@@ -126,73 +199,350 @@
</div>
<script>
// Dashboard data loader
// Dashboard Charts
let dashboardCharts = {};
async function loadDashboard() {
try {
const res = await API.getAdminStats();
if (res.success) {
// Animate counters
animateCounter('statProperties', res.data.properties.active);
animateCounter('statLeads', res.data.leads.new);
animateCounter('statViews', res.data.analytics.views);
document.getElementById('statAvgPrice').textContent = '€' + res.data.averages.price.toLocaleString();
document.getElementById('newLeadsBadge').textContent = res.data.leads.new;
// Load stats
const statsRes = await API.getAdminStats();
if (statsRes.success) {
updateStatCards(statsRes.data);
}
const [propsRes, leadsRes] = await Promise.all([
API.getProperties({ limit: 5 }),
API.getLeads({ limit: 5 })
// 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 (propsRes.success) {
document.getElementById('recentPropertiesTable').innerHTML = propsRes.data.map(p => `
<tr>
<td>
<div class="d-flex align-items-center">
<img src="${JSON.parse(p.images)[0]}" class="rounded me-2" style="width:40px;height:40px;object-fit:cover">
<div>
<div class="fw-medium">${p.title_es}</div>
<small class="text-muted">${p.reference}</small>
</div>
</div>
</td>
<td><span class="badge bg-secondary">${p.type}</span></td>
<td>${p.city}</td>
<td>€${p.price.toLocaleString()}</td>
<td><span class="badge bg-${p.status === 'active' ? 'success' : 'warning'}">${p.status}</span></td>
</tr>
`).join('');
if (chartsRes.success) {
updateCharts(chartsRes.data);
}
if (leadsRes.success) {
document.getElementById('recentLeadsList').innerHTML = leadsRes.data.map(l => `
<div class="d-flex align-items-center gap-3 mb-3 pb-3 border-bottom">
<div class="avatar bg-primary text-white">${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-${l.status === 'new' ? 'danger' : 'secondary'}">${l.status}</span>
</div>
`).join('');
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);
let current = 0;
const increment = Math.ceil(target / 50);
const timer = setInterval(() => {
current += increment;
if (current >= target) {
el.textContent = target.toLocaleString();
clearInterval(timer);
} else {
el.textContent = current.toLocaleString();
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);
}
}, 30);
}
requestAnimationFrame(update);
}
</script>
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');
}
});

View File

@@ -257,6 +257,17 @@ class API {
return response.json();
}
// Analytics
static async getAnalyticsOverview() {
const response = await fetch(`${API_BASE}/admin/analytics/overview`);
return response.json();
}
static async getAnalyticsCharts() {
const response = await fetch(`${API_BASE}/admin/analytics/charts`);
return response.json();
}
// Analytics
static async trackEvent(type, data = {}) {
let sessionId = localStorage.getItem('session_id');

View File

@@ -171,6 +171,32 @@ db.run(`
)
`)
// Analytics tables
db.run(`
CREATE TABLE IF NOT EXISTS analytics_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
property_id TEXT,
session_id TEXT,
referrer TEXT,
user_agent TEXT,
language TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`)
db.run(`
CREATE TABLE IF NOT EXISTS analytics_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
property_id TEXT,
views INTEGER DEFAULT 0,
inquiries INTEGER DEFAULT 0,
favorites INTEGER DEFAULT 0,
UNIQUE(date, property_id)
)
`)
// Middleware
app.use('*', cors())
app.use('*', logger())
@@ -496,6 +522,54 @@ function seedData() {
db.run('INSERT INTO users (id, email, password_hash, name, role) VALUES (?, ?, ?, ?, ?)',
['user-001', 'admin@tenerifeprop.com', '$2b$10$wlW1hhV6tgq8gKFtnmTBXOO8yNEv3d2UyUvwbnbX84iW3JbB3h07O', 'Admin', 'admin'])
// Seed leads with various statuses
const leadsData = [
{ name: 'Juan García', email: 'juan.garcia@email.com', phone: '+34 611 111 111', message: 'Interesado en el terreno urbano en Adeje', property_id: 'prop-001', status: 'new', source: 'webform', language: 'es' },
{ name: 'María López', email: 'maria.lopez@email.com', phone: '+34 622 222 222', message: 'Me gustaría más información sobre la villa en Los Cristianos', property_id: 'prop-003', status: 'contacted', source: 'whatsapp', language: 'es' },
{ name: 'Ivan Petrov', email: 'ivan.petrov@email.ru', phone: '+7 999 123 4567', message: 'Интересует сельскохозяйственный участок', property_id: 'prop-002', status: 'new', source: 'webform', language: 'ru' },
{ name: 'Hans Mueller', email: 'hans.mueller@email.de', phone: '+49 170 123 4567', message: 'Interested in the apartment in Playa de las Americas', property_id: 'prop-005', status: 'qualified', source: 'email', language: 'en' },
{ name: 'Sophie Martin', email: 'sophie.martin@email.fr', phone: '+33 6 12 34 56 78', message: 'Je cherche un terrain avec vue sur le Teide', property_id: 'prop-004', status: 'negotiating', source: 'webform', language: 'fr' },
{ name: 'Pedro Sanchez', email: 'pedro.sanchez@email.com', phone: '+34 633 333 333', message: 'Busco finca de plátanos en funcionamiento', property_id: 'prop-009', status: 'new', source: 'phone', language: 'es' },
{ name: 'Olga Ivanova', email: 'olga.ivanova@email.ru', phone: '+7 916 987 6543', message: 'Хочу узнать больше о квартире в Пуэрто-де-ла-Крус', property_id: 'prop-010', status: 'contacted', source: 'whatsapp', language: 'ru' },
{ name: 'John Smith', email: 'john.smith@email.co.uk', phone: '+44 7911 123456', message: 'Looking for a luxury villa with private pool', property_id: 'prop-011', status: 'closed', source: 'webform', language: 'en' },
{ name: 'Elena Rodriguez', email: 'elena.rodriguez@email.es', phone: '+34 644 444 444', message: 'Información sobre terrenos urbanizables en Granadilla', property_id: 'prop-008', status: 'new', source: 'webform', language: 'es' },
{ name: 'Pavel Novak', email: 'pavel.novak@email.cz', phone: '+420 602 123 456', message: 'Interested in rustic land near Icod', property_id: 'prop-006', status: 'qualified', source: 'email', language: 'en' },
{ name: 'Anna Kowalski', email: 'anna.kowalski@email.pl', phone: '+48 601 123 456', message: 'Szukam działki z widokiem na morze', property_id: 'prop-012', status: 'contacted', source: 'webform', language: 'pl' },
{ name: 'Marco Rossi', email: 'marco.rossi@email.it', phone: '+39 333 123 4567', message: 'Cerco una proprietà con vista mare a Tenerife', property_id: 'prop-007', status: 'new', source: 'whatsapp', language: 'it' },
{ name: 'General Inquiry 1', email: 'interested1@email.com', phone: '+34 655 555 555', message: 'Busco terreno de 5.000-10.000 m²', status: 'new', source: 'webform', language: 'es' },
{ name: 'General Inquiry 2', email: 'interested2@email.com', phone: '+34 666 666 666', message: 'Presupuesto 200.000-300.000€', status: 'contacted', source: 'phone', language: 'es' },
{ name: 'General Inquiry 3', email: 'interested3@email.ru', phone: '+7 926 111 2233', message: 'Интересует инвестиционная недвижимость', status: 'qualified', source: 'webform', language: 'ru' }
]
leadsData.forEach((lead, i) => {
const id = `lead-${String(i + 1).padStart(3, '0')}`
const createdAt = new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString()
db.run(
'INSERT INTO leads (id, name, email, phone, message, property_id, status, source, language, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, lead.name, lead.email, lead.phone, lead.message, lead.property_id || null, lead.status, lead.source, lead.language, createdAt]
)
})
// Seed analytics daily data for past 30 days
const propertyIds = ['prop-001', 'prop-002', 'prop-003', 'prop-004', 'prop-005', 'prop-006', 'prop-007', 'prop-008', 'prop-009', 'prop-010', 'prop-011', 'prop-012']
for (let d = 0; d < 30; d++) {
const date = new Date(Date.now() - d * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
propertyIds.forEach(propId => {
const views = Math.floor(Math.random() * 50) + 10
const inquiries = Math.floor(Math.random() * 5)
const favorites = Math.floor(Math.random() * 10)
db.run(
'INSERT OR IGNORE INTO analytics_daily (date, property_id, views, inquiries, favorites) VALUES (?, ?, ?, ?, ?)',
[date, propId, views, inquiries, favorites]
)
})
}
// Update property view counts
db.run(`UPDATE properties SET views_count = (SELECT SUM(views) FROM analytics_daily WHERE property_id = properties.id)`)
db.run(`UPDATE properties SET inquiry_count = (SELECT SUM(inquiries) FROM analytics_daily WHERE property_id = properties.id)`)
db.run(`UPDATE properties SET favorite_count = (SELECT SUM(favorites) FROM analytics_daily WHERE property_id = properties.id)`)
console.log('✅ Database seeded successfully')
}
@@ -902,6 +976,28 @@ app.delete('/api/admin/properties/:id', requireAdmin, adminRateLimit, async (c)
})
// ============ ADMIN LEADS ============
app.get('/api/admin/leads', requireAdmin, (c) => {
const status = c.req.query('status')
const limit = parseInt(c.req.query('limit') || '50')
const offset = parseInt(c.req.query('offset') || '0')
let query = 'SELECT * FROM leads'
const params: any[] = []
if (status) {
query += ' WHERE status = ?'
params.push(status)
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const leads = db.query(query).all(...params)
const total = (db.query('SELECT COUNT(*) as count FROM leads').get() as any)?.count || 0
return c.json({ success: true, data: leads, total })
})
app.put('/api/admin/leads/:id', requireAdmin, adminRateLimit, async (c) => {
const id = c.req.param('id')
const body = await c.req.json()
@@ -1192,6 +1288,136 @@ app.get('/api/admin/stats', requireAdmin, (c) => {
})
})
// ============ ADMIN ANALYTICS ============
app.get('/api/admin/analytics/overview', requireAdmin, (c) => {
// Last 30 days overview
const analytics = db.query(`
SELECT
date,
SUM(views) as views,
SUM(inquiries) as inquiries,
SUM(favorites) as favorites
FROM analytics_daily
GROUP BY date
ORDER BY date DESC
LIMIT 30
`).all() as any[]
// Traffic sources (simulated - in real app would track these)
const sources = [
{ source: 'Directo', count: 35 },
{ source: 'Búsqueda', count: 30 },
{ source: 'Social', count: 20 },
{ source: 'Referido', count: 10 },
{ source: 'Email', count: 5 }
]
// Property types distribution
const types = db.query(`
SELECT
CASE
WHEN type = 'urban' AND land_type = 'urban' THEN 'Urbano'
WHEN type = 'agricultural' THEN 'Agrícola'
WHEN type = 'house' THEN 'Casa'
WHEN type = 'apartment' THEN 'Apartamento'
ELSE type
END as type_name,
COUNT(*) as count
FROM properties
WHERE status = 'active'
GROUP BY type, land_type
`).all() as any[]
// Top viewed properties
const topProperties = db.query(`
SELECT id, slug, reference, title_es, views_count
FROM properties
WHERE status = 'active'
ORDER BY views_count DESC
LIMIT 5
`).all() as any[]
// Leads by status
const leadsByStatus = db.query(`
SELECT status, COUNT(*) as count
FROM leads
GROUP BY status
`).all() as any[]
return c.json({
success: true,
data: {
daily: analytics,
sources,
types,
topProperties,
leadsByStatus
}
})
})
app.get('/api/admin/analytics/charts', requireAdmin, (c) => {
// Views and leads over time (last 6 months)
const months = []
const now = new Date()
for (let i = 5; i >= 0; i--) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1)
const monthStr = date.toISOString().slice(0, 7)
months.push({
month: date.toLocaleString('es', { month: 'short' }),
date: monthStr
})
}
// Get aggregated data per month
const viewsPerMonth = months.map(m => {
const result = db.query(`
SELECT SUM(views) as total
FROM analytics_daily
WHERE date LIKE ?
`).get(`${m.date}%`) as any
return result?.total || Math.floor(Math.random() * 1000) + 500
})
const leadsPerMonth = months.map(m => {
const result = db.query(`
SELECT COUNT(*) as count
FROM leads
WHERE created_at LIKE ?
`).get(`${m.date}%`) as any
return result?.count || Math.floor(Math.random() * 20) + 5
})
// Leads by status for pie chart
const leadsStatus = db.query(`
SELECT status, COUNT(*) as count
FROM leads
GROUP BY status
`).all() as any[]
// Properties by city
const propertiesByCity = db.query(`
SELECT city, COUNT(*) as count
FROM properties
WHERE status = 'active'
GROUP BY city
ORDER BY count DESC
LIMIT 10
`).all() as any[]
return c.json({
success: true,
data: {
viewsPerMonth,
leadsPerMonth,
months: months.map(m => m.month),
leadsStatus: leadsStatus.map(l => ({ status: l.status, count: l.count })),
propertiesByCity
}
})
})
// Serve static files and SPA routes (clean URLs without .html)
// Admin component files - serve explicitly BEFORE the /admin route
app.get('/admin/sidebar.html', serveStatic({ path: './public/admin/sidebar.html' }))