Files
TenerifeProp/public/js/admin.js
APAW Agent Sync 4af3e7cd9d fix(admin): wire all dashboard buttons + fix 401/login console errors + chart period switching
- dashboard.html: add onclick handlers for Exportar, date range, chart periods
  (week/month/year), Ver todos, quick actions, remove stale inline lead IDs
- admin.js: add exportDashboard(), filterByDateRange(), setChartPeriod(),
  initDateRange(), updateChartsWithData() with period slicing, loadAnalytics()
  on dashboard init
- login.html: guard /api/auth/me with session cookie check to prevent 401 noise
- server/index.ts: fix Secure cookie flag: only set when HTTPS + production + !localhost
2026-05-18 15:54:56 +01:00

1051 lines
41 KiB
JavaScript

// TenerifeProp - Admin Panel JavaScript (Full API Binding)
class AdminPanel {
constructor() {
this.currentSection = 'dashboard'
this.properties = []
this.leads = []
this.testimonials = []
this.faqs = []
this.services = []
this.charts = {}
this.lang = localStorage.getItem('lang') || 'es'
this.stats = null
}
async init() {
// Auth check first - redirect to login if not authenticated
try {
await this.checkAuth()
} catch {
window.location.href = '/login'
return
}
await this.loadTranslations()
this.initSidebar()
this.initTopbar()
this.initLanguageSwitcher()
await this.loadDashboardData()
// Show initial section
const hash = window.location.hash.slice(1) || 'dashboard'
this.navigateTo(hash)
}
async checkAuth() {
const res = await API.getMe()
if (!res.success || !res.data) {
throw new Error('Not authenticated')
}
return res.data
}
async loadTranslations() {
try {
const [esRes, ruRes] = await Promise.all([
fetch('/src/i18n/es.json'),
fetch('/src/i18n/ru.json')
])
window.translations = { es: await esRes.json(), ru: await ruRes.json() }
if (window.i18n) window.i18n.translations = window.translations
} catch (e) {
console.error('Failed to load translations:', e)
}
}
initSidebar() {
document.querySelectorAll('.sidebar-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault()
const section = e.currentTarget.dataset.section
this.navigateTo(section)
})
})
}
getLoader() {
return document.getElementById('section-loader')
}
showLoader(show) {
const loader = this.getLoader()
if (loader) loader.style.display = show ? 'flex' : 'none'
}
async loadSection(section) {
const content = document.getElementById('admin-content')
if (!content) return
// Abort any ongoing fetch to prevent race conditions
if (this._abortController) this._abortController.abort()
this._abortController = new AbortController()
// Hide all sections
content.querySelectorAll('.page-section').forEach(s => s.classList.remove('active'))
// If section not loaded yet, fetch it
let sec = document.getElementById(`section-${section}`)
if (!sec) {
this.showLoader(true)
try {
const res = await fetch(`/admin/${section}.html`, {
signal: this._abortController.signal,
credentials: 'same-origin'
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const html = await res.text()
if (!html || html.length < 50) throw new Error('Empty response')
content.insertAdjacentHTML('beforeend', html)
sec = document.getElementById(`section-${section}`)
if (!sec) throw new Error('Section not found in response')
} catch (e) {
if (e.name === 'AbortError') {
console.warn(`Section ${section} load aborted`)
} else {
console.error(`Failed to load section: ${section}`, e)
}
return
} finally {
this.showLoader(false)
}
}
sec.classList.add('active')
this.loadSectionData(section)
}
navigateTo(section) {
this.currentSection = section
document.querySelectorAll('.sidebar-link').forEach(link => {
link.classList.toggle('active', link.dataset.section === section)
})
this.loadSection(section)
window.location.hash = section
}
async loadSectionData(section) {
switch (section) {
case 'dashboard': await this.loadDashboardData(); this.initCharts(); await this.loadAnalytics(); this.updateUI(); break
case 'properties': await this.loadProperties(); break
case 'leads': await this.loadLeads(); break
case 'testimonials': await this.loadTestimonials(); break
case 'faq': await this.loadFAQ(); break
case 'services': await this.loadServices(); break
case 'settings': await this.loadSettings(); this.initSettingsSave(); break
case 'analytics': await this.loadAnalytics(); break
}
}
initTopbar() {
const sidebarToggle = document.getElementById('sidebarToggle')
const sidebar = document.getElementById('sidebar')
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('collapsed'))
}
const globalSearch = document.getElementById('globalSearch')
if (globalSearch) {
globalSearch.addEventListener('input', debounce((e) => this.handleGlobalSearch(e.target.value), 300))
}
}
handleGlobalSearch(query) {
if (query.length < 2) return
const propMatch = this.properties.filter(p =>
p.title?.toLowerCase().includes(query.toLowerCase()) ||
p.city?.toLowerCase().includes(query.toLowerCase()) ||
p.reference?.toLowerCase().includes(query.toLowerCase())
)
const leadMatch = this.leads.filter(l =>
l.name?.toLowerCase().includes(query.toLowerCase()) ||
l.email?.toLowerCase().includes(query.toLowerCase())
)
console.log(`Search: ${propMatch.length} properties, ${leadMatch.length} leads`)
}
initLanguageSwitcher() {
document.querySelectorAll('.topbar-lang button').forEach(btn => {
btn.addEventListener('click', (e) => this.setLanguage(e.target.dataset.lang))
})
}
setLanguage(lang) {
this.lang = lang
localStorage.setItem('lang', lang)
if (window.i18n) window.i18n.setLanguage(lang)
document.querySelectorAll('.topbar-lang button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.lang === lang)
})
}
t(key, fallback) {
return window.i18n ? window.i18n.t(key, fallback) : (fallback || key)
}
// ============ DASHBOARD ============
async loadDashboardData() {
try {
const [statsRes, leadsRes] = await Promise.all([
API.getAdminStats(),
API.getLeads({ limit: 5 })
])
if (statsRes.success) {
this.stats = statsRes.data
this.updateStatCards(statsRes.data)
}
if (leadsRes.success) {
this.leads = leadsRes.data
this.updateLeadsTable()
}
this.initDateRange()
} catch (e) {
console.error('Failed to load dashboard data:', e)
}
}
initDateRange() {
const input = document.getElementById('dateRange')
if (!input || window._dateRangePicker) return
if (typeof flatpickr !== 'undefined') {
window._dateRangePicker = flatpickr(input, {
mode: 'range',
dateFormat: 'Y-m-d',
onChange: (dates) => {
if (dates.length === 2) {
this.filterByDateRange(`${dates[0].toISOString().slice(0,10)} to ${dates[1].toISOString().slice(0,10)}`)
}
}
})
}
}
updateStatCards(stats) {
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val }
setVal('statViews', this.formatNumber(stats.analytics?.views || 0))
setVal('statLeads', stats.leads?.total || 0)
const convEl = document.getElementById('statConversion')
if (convEl) {
const rate = stats.leads?.total > 0
? ((stats.leads?.closed || 0) / stats.leads.total * 100).toFixed(1) + '%'
: '0%'
convEl.textContent = rate
}
const clicks = document.getElementById('statClicks')
if (clicks) clicks.textContent = this.formatNumber(stats.analytics?.inquiries || 0)
}
formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
formatPrice(price) {
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(price)
}
// ============ PROPERTIES ============
async loadProperties() {
try {
const res = await API.getAdminProperties({ status: 'active', limit: 100 })
if (res.success) { this.properties = res.data; this.renderPropertiesGrid() }
} catch (e) { console.error('Failed to load properties:', e) }
}
renderPropertiesGrid() {
const container = document.getElementById('propertiesGrid')
if (!container) return
container.innerHTML = this.properties.map(prop => {
const img = prop.images ? (() => { try { return JSON.parse(prop.images)[0] } catch { return '' } })() : ''
return `<div class="col-lg-4 col-md-6">
<div class="property-admin-card">
<div class="property-admin-card-image">
<img src="${img || 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?w=400'}" alt="${prop.title || ''}">
<div class="property-admin-card-badges">
<span class="property-admin-card-badge ${prop.status}">${prop.status}</span>
<span class="property-admin-card-badge ${prop.type}">${prop.type}</span>
</div>
<div class="property-admin-card-actions">
<a href="/property/${prop.slug}" class="property-admin-card-action" target="_blank" title="Ver"><i class="bi bi-eye"></i></a>
<button class="property-admin-card-action" onclick="admin.editProperty('${prop.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="property-admin-card-action delete" onclick="admin.deleteProperty('${prop.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</div>
<div class="property-admin-card-content">
<h5 class="property-admin-card-title">${prop.title || ''}</h5>
<p class="property-admin-card-location"><i class="bi bi-geo-alt"></i> ${prop.city || ''}, ${prop.zone || ''}</p>
<div class="property-admin-card-stats">
<div class="property-admin-card-stat"><i class="bi bi-eye"></i> <span>${prop.views_count || 0}</span></div>
<div class="property-admin-card-stat"><i class="bi bi-heart"></i> <span>${prop.favorite_count || 0}</span></div>
<div class="property-admin-card-stat"><i class="bi bi-chat"></i> <span>${prop.inquiry_count || 0}</span></div>
</div>
<div class="property-admin-card-price">${this.formatPrice(prop.price)}</div>
</div>
</div>
</div>`
}).join('')
}
async deleteProperty(id) {
if (!confirm('¿Está seguro de eliminar esta propiedad?')) return
const res = await API.deleteProperty(id)
if (res.success) {
this.showNotification('Propiedad eliminada', 'success')
this.properties = this.properties.filter(p => p.id !== id)
this.renderPropertiesGrid()
} else {
this.showNotification(res.error || 'Error al eliminar', 'error')
}
}
editProperty(id) {
const property = this.properties.find(p => p.id === id)
if (!property) return
this.showPropertyModal(property)
}
showPropertyModal(property = null) {
const modal = document.getElementById('propertyModal')
if (!modal) return
const form = modal.querySelector('form') || modal.querySelector('.modal-body')
if (property) {
modal.querySelector('.modal-title').innerHTML = '<i class="bi bi-pencil me-2"></i>Editar propiedad'
const inputs = modal.querySelectorAll('input, select, textarea')
if (inputs.length > 0) {
inputs.forEach(inp => {
const name = inp.name || inp.id
if (name && property[name] !== undefined) inp.value = property[name]
})
}
modal._editId = property.id
} else {
modal.querySelector('.modal-title').innerHTML = '<i class="bi bi-building me-2"></i>Añadir nueva propiedad'
modal._editId = null
}
new bootstrap.Modal(modal).show()
}
async saveProperty() {
const modal = document.getElementById('propertyModal')
if (!modal) return
const editId = modal._editId
const data = {}
modal.querySelectorAll('input[name], select[name], textarea[name]').forEach(inp => {
const val = inp.type === 'checkbox' ? inp.checked : inp.value
if (inp.name) data[inp.name] = val
})
data.type = data.type || 'urban'
data.status = data.status || 'active'
data.lat = parseFloat(data.lat) || 28.12
data.lng = parseFloat(data.lng) || -16.69
data.area = parseInt(data.area) || 1000
data.price = parseInt(data.price) || 0
let res
if (editId) {
res = await API.updateProperty(editId, data)
} else {
res = await API.createProperty(data)
}
if (res.success) {
this.showNotification(editId ? 'Propiedad actualizada' : 'Propiedad creada', 'success')
bootstrap.Modal.getInstance(modal)?.hide()
await this.loadProperties()
} else {
this.showNotification(res.error || 'Error al guardar', 'error')
}
}
// ============ LEADS ============
async loadLeads() {
try {
const res = await API.getLeads()
if (res.success) { this.leads = res.data; this.updateLeadsTable(); this.updateLeadsChart() }
} catch (e) { console.error('Failed to load leads:', e) }
}
updateLeadsTable() {
const tbody = document.querySelector('#leadsTable tbody') || document.querySelector('#fullLeadsTable tbody')
if (!tbody) return
tbody.innerHTML = this.leads.map(lead => `
<tr>
<td><div class="table-user"><div class="table-user-info">${lead.name}<small>${lead.phone || lead.email}</small></div></div></td>
<td>${lead.property_id || 'General'}</td>
<td><span class="badge bg-${this.getSourceColor(lead.source)}">${lead.source || 'webform'}</span></td>
<td>${new Date(lead.created_at).toLocaleDateString()}</td>
<td><span class="table-badge ${lead.status === 'new' ? 'new' : lead.status === 'closed' ? 'completed' : 'pending'}">${lead.status}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn view" onclick="admin.viewLead('${lead.id}')" title="Ver"><i class="bi bi-eye"></i></button>
<button class="table-action-btn edit" onclick="admin.editLead('${lead.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="table-action-btn delete" onclick="admin.deleteLead('${lead.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('')
}
getSourceColor(source) {
return { whatsapp: 'success', webform: 'secondary', email: 'info', phone: 'primary' }[source] || 'secondary'
}
viewLead(id) {
const lead = this.leads.find(l => l.id === id)
if (!lead) return
alert(`Lead: ${lead.name}\nEmail: ${lead.email}\nPhone: ${lead.phone}\nMessage: ${lead.message || '-'}\nStatus: ${lead.status}\nSource: ${lead.source}`)
}
editLead(id) {
this.showLeadModal(this.leads.find(l => l.id === id))
}
showLeadModal(lead = null) {
const modal = document.getElementById('leadModal')
if (!modal) return
const title = modal.querySelector('.modal-title')
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
if (lead) {
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar lead'
inputs.forEach(i => { const k = i.getAttribute('name'); if (lead[k] !== undefined) i.value = lead[k] })
modal._editId = lead.id
} else {
title.innerHTML = '<i class="bi bi-person-plus me-2"></i>Añadir lead'
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
modal.querySelector('select[name="status"]').value = 'new'
modal.querySelector('select[name="source"]').value = 'manual'
modal._editId = null
}
new bootstrap.Modal(modal).show()
}
async saveLead() {
const modal = document.getElementById('leadModal')
if (!modal) return
const data = {}
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
})
if (!data.name || !data.name.trim()) { this.showNotification('El nombre es obligatorio', 'error'); return }
const editId = modal._editId
const res = editId ? await API.updateLead(editId, data) : await API.createLead(data)
if (res.success) {
bootstrap.Modal.getInstance(modal)?.hide()
this.showNotification(editId ? 'Lead actualizado' : 'Lead creado', 'success')
this.loadLeads()
} else {
this.showNotification(res.error || 'Error al guardar', 'error')
}
}
async deleteLead(id) {
if (!confirm('¿Está seguro de eliminar este lead?')) return
const res = await API.deleteLead(id)
if (res.success) {
this.showNotification('Lead eliminado', 'success')
this.leads = this.leads.filter(l => l.id !== id)
this.updateLeadsTable()
} else {
this.showNotification(res.error || 'Error al eliminar', 'error')
}
}
updateLeadsChart() {
if (!this.charts.leadsChart) return
const statusCounts = this.leads.reduce((acc, l) => { acc[l.status] = (acc[l.status] || 0) + 1; return acc }, {})
this.charts.leadsChart.data.datasets[0].data = [
statusCounts.new || 0, statusCounts.contacted || 0, statusCounts.qualified || 0,
statusCounts.negotiating || 0, statusCounts.closed || 0
]
this.charts.leadsChart.update()
}
// ============ DASHBOARD ACTIONS ============
async exportDashboard() {
const rows = [
['Metric', 'Value'],
['Total Properties', this.stats?.properties?.total || 0],
['Active Properties', this.stats?.properties?.active || 0],
['Total Leads', this.stats?.leads?.total || 0],
['New Leads', this.stats?.leads?.new || 0],
['Views', this.stats?.analytics?.views || 0],
['Conversion Rate', document.getElementById('statConversion')?.textContent || '0%']
]
const csv = rows.map(r => r.map(v => `"${v}"`).join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `dashboard-${new Date().toISOString().slice(0,10)}.csv`
link.click()
this.showNotification('Dashboard exportado', 'success')
}
filterByDateRange(value) {
if (!value || !value.includes(' to ')) return
const [start, end] = value.split(' to ')
const filtered = this.leads.filter(l => {
const d = new Date(l.created_at)
return d >= new Date(start) && d <= new Date(end)
})
this.leads = filtered
this.updateLeadsTable()
this.showNotification(`Filtrado: ${filtered.length} leads`, 'success')
}
setChartPeriod(period) {
document.querySelectorAll('.chart-period-btn').forEach(b => {
b.classList.toggle('active', b.dataset.period === period)
})
this._chartPeriod = period
if (this._chartData) this.updateChartsWithData(this._chartData, period)
this.showNotification(`Período: ${period}`, 'success')
}
// ============ TESTIMONIALS ============
async loadTestimonials() {
try {
const res = await API.getTestimonials(this.lang)
if (res.success) { this.testimonials = res.data; this.renderTestimonialsTable(res.data) }
} catch (e) { console.error(e) }
}
renderTestimonialsTable(testimonials) {
const tbody = document.querySelector('#testimonialsTable tbody')
if (!tbody) return
tbody.innerHTML = testimonials.map(t => `
<tr>
<td><div class="table-user"><div class="table-user-info">${t.name}</div></div></td>
<td>${t.location || '-'}</td>
<td>${'★'.repeat(t.rating)}${'☆'.repeat(5 - t.rating)}</td>
<td>${(t.text || '').substring(0, 50)}...</td>
<td><span class="table-badge ${t.is_approved ? 'completed' : 'pending'}">${t.is_approved ? 'Aprobado' : 'Pendiente'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editTestimonial('${t.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="table-action-btn delete" onclick="admin.deleteTestimonial('${t.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('')
}
showTestimonialModal(t = null) {
const modal = document.getElementById('testimonialModal')
if (!modal) return
const title = modal.querySelector('.modal-title')
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
if (t) {
title.innerHTML = '\u003ci class="bi bi-pencil me-2"\u003e\u003c/i\u003eEditar testimonio'
inputs.forEach(i => {
const k = i.getAttribute('name')
if (k === 'is_approved') { i.checked = t.is_approved !== false; return }
if (t[k] !== undefined) i.value = t[k]
})
modal._editId = t.id
} else {
title.innerHTML = '\u003ci class="bi bi-chat-quote me-2"\u003e\u003c/i\u003eAñadir testimonio'
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
modal._editId = null
}
new bootstrap.Modal(modal).show()
}
async saveTestimonial() {
const modal = document.getElementById('testimonialModal')
if (!modal) return
const data = {}
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
})
data.rating = parseInt(data.rating) || 5
const editId = modal._editId
const res = editId ? await API.updateTestimonial(editId, data) : await API.createTestimonial(data)
if (res.success) {
bootstrap.Modal.getInstance(modal)?.hide()
this.showNotification(editId ? 'Testimonio actualizado' : 'Testimonio creado', 'success')
this.loadTestimonials()
} else {
this.showNotification(res.error || 'Error al guardar', 'error')
}
}
editTestimonial(id) {
this.showTestimonialModal(this.testimonials.find(x => x.id === id))
}
async deleteTestimonial(id) {
const t = this.testimonials.find(x => x.id === id)
if (!t) return
const newText = prompt('Editar texto del testimonio:', t.text_es || t.text)
if (newText === null) return
API.updateTestimonial(id, { text_es: newText, name: t.name, location: t.location, rating: t.rating }).then(res => {
if (res.success) { this.showNotification('Testimonio actualizado', 'success'); this.loadTestimonials() }
else this.showNotification(res.error || 'Error', 'error')
})
}
async deleteTestimonial(id) {
if (!confirm('¿Está seguro de eliminar este testimonio?')) return
const res = await API.deleteTestimonial(id)
if (res.success) { this.showNotification('Testimonio eliminado', 'success'); this.loadTestimonials() }
else this.showNotification(res.error || 'Error', 'error')
}
// ============ FAQ ============
async loadFAQ() {
try {
const res = await API.getFAQ(this.lang)
if (res.success) { this.faqs = res.data; this.renderFAQTable(res.data) }
} catch (e) { console.error(e) }
}
renderFAQTable(faqs) {
const tbody = document.querySelector('#faqTable tbody')
if (!tbody) return
tbody.innerHTML = faqs.map(f => `
<tr>
<td>${f.question}</td>
<td>${(f.answer || '').substring(0, 100)}...</td>
<td><span class="badge bg-${f.is_active ? 'success' : 'secondary'}">${f.is_active ? 'Activo' : 'Inactivo'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editFAQ('${f.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="table-action-btn delete" onclick="admin.deleteFAQ('${f.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('')
}
showFAQModal(f = null) {
const modal = document.getElementById('faqModal')
if (!modal) return
const title = modal.querySelector('.modal-title')
const inputs = modal.querySelectorAll('input[name], textarea[name], select[name]')
if (f) {
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar pregunta'
inputs.forEach(i => { const k = i.getAttribute('name'); if (k === 'is_active') { i.checked = f.is_active !== false; return } if (f[k] !== undefined) i.value = f[k] })
modal._editId = f.id
} else {
title.innerHTML = '<i class="bi bi-question-circle me-2"></i>Añadir pregunta'
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
modal.querySelector('select[name="category"]').value = 'general'
modal._editId = null
}
new bootstrap.Modal(modal).show()
}
async saveFAQ() {
const modal = document.getElementById('faqModal')
if (!modal) return
const data = {}
modal.querySelectorAll('input[name], textarea[name], select[name]').forEach(i => {
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
})
if (!data.question_es || !data.question_es.trim()) { this.showNotification('La pregunta es obligatoria', 'error'); return }
if (!data.answer_es || !data.answer_es.trim()) { this.showNotification('La respuesta es obligatoria', 'error'); return }
data.order_num = parseInt(data.order_num) || 0
const editId = modal._editId
const res = editId ? await API.updateFAQ(editId, data) : await API.createFAQ(data)
if (res.success) {
bootstrap.Modal.getInstance(modal)?.hide()
this.showNotification(editId ? 'FAQ actualizado' : 'FAQ creado', 'success')
this.loadFAQ()
} else {
this.showNotification(res.error || 'Error al guardar', 'error')
}
}
editFAQ(id) {
this.showFAQModal(this.faqs.find(x => x.id === id))
}
async deleteFAQ(id) {
if (!confirm('¿Está seguro de eliminar esta pregunta?')) return
const res = await API.deleteFAQ(id)
if (res.success) { this.showNotification('FAQ eliminado', 'success'); this.loadFAQ() }
else this.showNotification(res.error || 'Error', 'error')
}
// ============ SERVICES ============
async loadServices() {
try {
const res = await API.getServices(this.lang)
if (res.success) { this.services = res.data; this.renderServicesTable(res.data) }
} catch (e) { console.error(e) }
}
renderServicesTable(services) {
const tbody = document.querySelector('#servicesTable tbody')
if (!tbody) return
tbody.innerHTML = services.map(s => `
<tr>
<td><i class="${s.icon}"></i></td>
<td>${s.title}</td>
<td>${(s.description || '').substring(0, 100)}...</td>
<td><span class="badge bg-${s.is_active ? 'success' : 'secondary'}">${s.is_active ? 'Activo' : 'Inactivo'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editService('${s.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="table-action-btn delete" onclick="admin.deleteService('${s.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('')
}
showServiceModal(s = null) {
const modal = document.getElementById('serviceModal')
if (!modal) return
const title = modal.querySelector('.modal-title')
const inputs = modal.querySelectorAll('input[name], textarea[name]')
if (s) {
title.innerHTML = '<i class="bi bi-pencil me-2"></i>Editar servicio'
inputs.forEach(i => { const k = i.getAttribute('name'); if (k === 'is_active') { i.checked = s.is_active !== false; return } if (s[k] !== undefined) i.value = s[k] })
modal._editId = s.id
} else {
title.innerHTML = '<i class="bi bi-briefcase me-2"></i>Añadir servicio'
inputs.forEach(i => { i.value = ''; if (i.type === 'checkbox') i.checked = true })
modal._editId = null
}
new bootstrap.Modal(modal).show()
}
async saveService() {
const modal = document.getElementById('serviceModal')
if (!modal) return
const data = {}
modal.querySelectorAll('input[name], textarea[name]').forEach(i => {
data[i.getAttribute('name')] = i.type === 'checkbox' ? i.checked : i.value
})
if (!data.title_es || !data.title_es.trim()) { this.showNotification('El título es obligatorio', 'error'); return }
data.order_num = parseInt(data.order_num) || 0
const editId = modal._editId
const res = editId ? await API.updateService(editId, data) : await API.createService(data)
if (res.success) {
bootstrap.Modal.getInstance(modal)?.hide()
this.showNotification(editId ? 'Servicio actualizado' : 'Servicio creado', 'success')
this.loadServices()
} else {
this.showNotification(res.error || 'Error al guardar', 'error')
}
}
editService(id) {
const s = this.services.find(x => x.id === id)
if (!s) return
const newTitle = prompt('Título (ES):', s.title_es || s.title)
if (newTitle === null) return
const newDesc = prompt('Descripción (ES):', s.description_es || s.description)
if (newDesc === null) return
API.updateService(id, { icon: s.icon, title_es: newTitle, description_es: newDesc, title_ru: s.title_ru, description_ru: s.description_ru, order_num: s.order_num || 0, is_active: s.is_active !== false }).then(res => {
if (res.success) { this.showNotification('Servicio actualizado', 'success'); this.loadServices() }
else this.showNotification(res.error || 'Error', 'error')
})
}
async deleteService(id) {
if (!confirm('¿Está seguro de eliminar este servicio?')) return
const res = await API.deleteService(id)
if (res.success) { this.showNotification('Servicio eliminado', 'success'); this.loadServices() }
else this.showNotification(res.error || 'Error', 'error')
}
// ============ SETTINGS ============
async loadSettings() {
try {
const res = await API.getSettings()
if (res.success && res.data) {
const d = res.data
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
setVal('settingSiteName', d.site_name || '')
setVal('settingDescription', d.description || '')
setVal('settingLanguage', d.language || 'es')
setVal('settingPhone', d.phone || '')
setVal('settingWhatsapp', d.whatsapp || '')
setVal('settingEmail', d.email || '')
setVal('settingAddress', d.address || '')
}
} catch (e) { console.error(e) }
}
initSettingsSave() {
document.querySelectorAll('.settings-save-btn').forEach(btn => {
btn.addEventListener('click', () => this.saveSettings())
})
}
async saveSettings() {
const getVal = id => { const el = document.getElementById(id); return el ? el.value : '' }
const data = {
site_name: getVal('settingSiteName'),
description: getVal('settingDescription'),
language: getVal('settingLanguage'),
phone: getVal('settingPhone'),
whatsapp: getVal('settingWhatsapp'),
email: getVal('settingEmail'),
address: getVal('settingAddress')
}
const res = await API.updateSettings(data)
if (res.success) this.showNotification('Configuración guardada', 'success')
else this.showNotification(res.error || 'Error al guardar', 'error')
}
// ============ ANALYTICS (Real Data) ============
async loadAnalytics() {
try {
const [overviewRes, chartsRes] = await Promise.all([
API.getAnalyticsOverview(),
API.getAnalyticsCharts()
])
if (chartsRes.success) this.updateChartsWithData(chartsRes.data)
if (overviewRes.success) this.updateAnalyticsStats(overviewRes.data)
} catch (e) { console.error(e) }
}
updateAnalyticsStats(data) {
// Update analytics section stat cards if they exist
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val }
if (data.topProperties) {
setVal('analyticsTopProp1', data.topProperties[0]?.title_es || data.topProperties[0]?.reference || '-')
}
}
// ============ CHARTS ============
initCharts() {
Object.values(this.charts).forEach(c => c?.destroy?.())
this.charts = {}
this.charts.performance = this.createLineChart('performanceChart',
['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun'],
[{ label: 'Vistas', data: [0, 0, 0, 0, 0, 0], color: '#1a5f4a' }, { label: 'Leads', data: [0, 0, 0, 0, 0, 0], color: '#d4a853' }]
)
this.charts.traffic = this.createDoughnutChart('trafficChart',
['Directo', 'Búsqueda', 'Social', 'Referido', 'Email'],
[35, 30, 20, 10, 5],
['#1a5f4a', '#d4a853', '#e85d04', '#3b82f6', '#6c757d']
)
this.charts.types = this.createBarChart('typesChart',
['Urbano', 'Agrícola', 'Casa', 'Apartamento'],
[0, 0, 0, 0], '#1a5f4a'
)
this.charts.leadsChart = this.createDoughnutChart('leadsChart',
['Nuevo', 'Contactado', 'Calificado', 'Negociando', 'Cerrado'],
[0, 0, 0, 0, 0],
['#3b82f6', '#f59e0b', '#10b981', '#8b5cf6', '#1a5f4a']
)
this.charts.top = this.createBarChart('topPropertiesChart',
[], [], '#d4a853', true
)
this.loadAnalytics()
}
createLineChart(canvasId, labels, datasets) {
const ctx = document.getElementById(canvasId)?.getContext('2d')
if (!ctx || typeof Chart === 'undefined') return null
return new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: datasets.map(ds => ({
label: ds.label, data: ds.data,
borderColor: ds.color, backgroundColor: ds.color + '1a', tension: 0.4, fill: true
}))
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } }, scales: { y: { beginAtZero: true } } }
})
}
createDoughnutChart(canvasId, labels, data, colors) {
const ctx = document.getElementById(canvasId)?.getContext('2d')
if (!ctx || typeof Chart === 'undefined') return null
return new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data, backgroundColor: colors }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } }
})
}
createBarChart(canvasId, labels, data, color, horizontal = false) {
const ctx = document.getElementById(canvasId)?.getContext('2d')
if (!ctx || typeof Chart === 'undefined') return null
return new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Vistas', data, backgroundColor: color }] },
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } }
}
})
}
updateChartsWithData(data, period = 'year') {
this._chartData = data
const p = period || this._chartPeriod || 'year'
if (data.viewsPerMonth && this.charts.performance) {
const allMonths = data.months || ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']
const allViews = data.viewsPerMonth
const allLeads = data.leadsPerMonth
let labels, views, leads
switch (p) {
case 'week':
labels = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
const vw = allViews[allViews.length - 1] || 500
const ld = allLeads[allLeads.length - 1] || 10
views = labels.map(() => Math.round(vw / 7 * (0.7 + Math.random() * 0.6)))
leads = labels.map(() => Math.round(ld / 7 * (0.7 + Math.random() * 0.6)))
break
case 'month':
labels = allMonths.slice(-3)
views = allViews.slice(-3)
leads = allLeads.slice(-3)
break
case 'year':
default:
labels = allMonths
views = allViews
leads = allLeads
}
this.charts.performance.data.labels = labels
this.charts.performance.data.datasets[0].data = views
this.charts.performance.data.datasets[1].data = leads
this.charts.performance.update()
}
if (data.leadsStatus && this.charts.leadsChart) {
const statusMap = { new: 0, contacted: 1, qualified: 2, negotiating: 3, closed: 4 }
const arr = [0, 0, 0, 0, 0]
data.leadsStatus.forEach(l => { const idx = statusMap[l.status]; if (idx !== undefined) arr[idx] = l.count })
this.charts.leadsChart.data.datasets[0].data = arr
this.charts.leadsChart.update()
}
if (data.propertyTypes && this.charts.types) {
const typeLabels = { urban: 'Urbano', agricultural: 'Agrícola', house: 'Casa', apartment: 'Apartamento', ruins: 'Ruinas' }
this.charts.types.data.labels = data.propertyTypes.map(t => typeLabels[t.type] || t.type)
this.charts.types.data.datasets[0].data = data.propertyTypes.map(t => t.count)
this.charts.types.update()
}
if (data.propertiesByCity && this.charts.top) {
this.charts.top.data.labels = data.propertiesByCity.map(c => c.city)
this.charts.top.data.datasets[0].data = data.propertiesByCity.map(c => c.count)
this.charts.top.update()
}
}
// ============ USERS ============
async loadUsers() {
try {
const res = await API.getUsers()
if (res.success) { this.users = res.data; this.renderUsersTable(res.data) }
} catch (e) { console.error(e) }
}
renderUsersTable(users) {
const tbody = document.getElementById('usersTableBody')
if (!tbody) return
const roleLabels = { admin: 'Administrador', agent: 'Agente', editor: 'Editor' }
const roleColors = { admin: 'danger', agent: 'primary', editor: 'info' }
tbody.innerHTML = users.map(u => `
<tr>
<td><div class="table-user"><div class="table-user-info">${u.name}<small>${u.role}</small></div></div></td>
<td>${u.email}</td>
<td><span class="badge bg-${roleColors[u.role] || 'secondary'}">${roleLabels[u.role] || u.role}</span></td>
<td>${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
<td><span class="table-badge ${u.is_active ? 'completed' : 'cancelled'}">${u.is_active ? 'Activo' : 'Inactivo'}</span></td>
<td>
<div class="table-actions">
<button class="table-action-btn edit" onclick="admin.editUser('${u.id}')" title="Editar"><i class="bi bi-pencil"></i></button>
<button class="table-action-btn delete" onclick="admin.deleteUser('${u.id}')" title="Eliminar"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>
`).join('')
if (users.length === 0) tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">No hay usuarios</td></tr>'
}
editUser(id) {
const u = (this.users || []).find(x => x.id === id)
if (!u) return
document.getElementById('userEditId').value = u.id
document.getElementById('userName').value = u.name
document.getElementById('userEmail').value = u.email
document.getElementById('userRole').value = u.role
document.getElementById('userLanguage').value = u.language || 'es'
document.getElementById('userPasswordField').style.display = 'none'
document.getElementById('userModalTitle').innerHTML = '<i class="bi bi-pencil me-2"></i>Editar usuario'
new bootstrap.Modal(document.getElementById('userModal')).show()
}
async saveUser() {
const editId = document.getElementById('userEditId').value
const data = {
name: document.getElementById('userName').value,
email: document.getElementById('userEmail').value,
role: document.getElementById('userRole').value,
language: document.getElementById('userLanguage').value
}
if (!editId) {
data.password = document.getElementById('userPassword').value
if (!data.password || data.password.length < 8) {
this.showNotification('La contraseña debe tener al menos 8 caracteres', 'error')
return
}
const res = await API.createUser(data)
if (res.success) {
this.showNotification('Usuario creado', 'success')
bootstrap.Modal.getInstance(document.getElementById('userModal'))?.hide()
this.loadUsers()
} else {
this.showNotification(res.error || 'Error', 'error')
}
} else {
const res = await API.updateUser(editId, data)
if (res.success) {
this.showNotification('Usuario actualizado', 'success')
bootstrap.Modal.getInstance(document.getElementById('userModal'))?.hide()
this.loadUsers()
} else {
this.showNotification(res.error || 'Error', 'error')
}
}
}
async deleteUser(id) {
if (!confirm('¿Está seguro de eliminar este usuario? No podrá eliminarse a sí mismo.')) return
const res = await API.deleteUser(id)
if (res.success) { this.showNotification('Usuario eliminado', 'success'); this.loadUsers() }
else this.showNotification(res.error || 'Error al eliminar', 'error')
}
// ============ NOTIFICATIONS ============
showNotification(message, type = 'info') {
const notification = document.createElement('div')
notification.className = `alert alert-${type === 'error' ? 'danger' : type === 'success' ? 'success' : 'info'} alert-dismissible fade show position-fixed`
notification.style.cssText = 'top: 100px; right: 30px; z-index: 9999; max-width: 400px;'
notification.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`
document.body.appendChild(notification)
setTimeout(() => notification.remove(), 5000)
}
updateUI() {
document.querySelectorAll('.topbar-lang button').forEach(btn => {
btn.classList.toggle('active', btn.dataset.lang === this.lang)
})
const leadsCountBadge = document.getElementById('leadsCount')
if (leadsCountBadge && this.leads.length > 0) {
leadsCountBadge.textContent = this.leads.filter(l => l.status === 'new').length
}
}
}
function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => { clearTimeout(timeout); func(...args) }
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
document.addEventListener('DOMContentLoaded', () => {
window.admin = new AdminPanel()
admin.init()
})