Files
TenerifeProp/public/js/admin.js
APAW Agent Sync 08e2d21f7d fix(client): use getAdminProperties in admin panel
- Add getAdminProperties() to api.js with admin endpoint
- Update admin.js loadProperties() to use getAdminProperties
- Returns full dataset with admin filtering support

Refs: production admin panel
2026-05-14 09:26:22 +01:00

800 lines
31 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 (e) {
console.error('Auth failed:', e)
window.location.href = '/login'
return
}
await this.loadTranslations()
this.initSidebar()
this.initTopbar()
this.initLanguageSwitcher()
this.initSettingsSave()
await this.loadDashboardData()
this.initCharts()
this.updateUI()
// 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)
})
})
}
async loadSection(section) {
const content = document.getElementById('admin-content')
if (!content) return
// 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) {
try {
const res = await fetch(`/admin/${section}.html`)
const html = await res.text()
content.insertAdjacentHTML('beforeend', html)
sec = document.getElementById(`section-${section}`)
if (!sec) return
} catch (e) {
console.error(`Failed to load section: ${section}`, e)
return
}
}
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(); 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(); 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()
}
} catch (e) {
console.error('Failed to load dashboard data:', e)
}
}
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) {
const lead = this.leads.find(l => l.id === id)
if (!lead) return
const newStatus = prompt(`Cambiar estado del lead (${lead.name}):\n\nOpciones: new, contacted, qualified, negotiating, closed, lost`, lead.status)
if (!newStatus || newStatus === lead.status) return
API.updateLead(id, { status: newStatus }).then(res => {
if (res.success) {
this.showNotification('Lead actualizado', 'success')
this.loadLeads()
} else {
this.showNotification(res.error || 'Error', '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()
}
// ============ 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('')
}
editTestimonial(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('')
}
editFAQ(id) {
const f = this.faqs.find(x => x.id === id)
if (!f) return
const newQ = prompt('Pregunta (ES):', f.question_es || f.question)
if (newQ === null) return
const newA = prompt('Respuesta (ES):', f.answer_es || f.answer)
if (newA === null) return
API.updateFAQ(id, { question_es: newQ, answer_es: newA, question_ru: f.question_ru, answer_ru: f.answer_ru, category: f.category || 'general', order_num: f.order_num || 0, is_active: f.is_active !== false }).then(res => {
if (res.success) { this.showNotification('FAQ actualizado', 'success'); this.loadFAQ() }
else this.showNotification(res.error || 'Error', 'error')
})
}
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('')
}
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('settingPhone', d.phone || '')
setVal('settingWhatsapp', d.whatsapp || '')
setVal('settingEmail', d.email || '')
}
} 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'),
phone: getVal('settingPhone'),
whatsapp: getVal('settingWhatsapp'),
email: getVal('settingEmail')
}
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() {
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
)
// Load real data immediately
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) {
if (data.viewsPerMonth && this.charts.performance) {
this.charts.performance.data.datasets[0].data = data.viewsPerMonth
this.charts.performance.data.datasets[1].data = data.leadsPerMonth
if (data.months) this.charts.performance.data.labels = data.months
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()
})