// 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() { 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 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.getProperties({ lang: this.lang, 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 `
${prop.title || ''}
${prop.status} ${prop.type}
${prop.title || ''}

${prop.city || ''}, ${prop.zone || ''}

${prop.views_count || 0}
${prop.favorite_count || 0}
${prop.inquiry_count || 0}
${this.formatPrice(prop.price)}
` }).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 = '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 = '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 => `
${lead.name}${lead.phone || lead.email}
${lead.property_id || 'General'} ${lead.source || 'webform'} ${new Date(lead.created_at).toLocaleDateString()} ${lead.status}
`).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 => `
${t.name}
${t.location || '-'} ${'★'.repeat(t.rating)}${'☆'.repeat(5 - t.rating)} ${(t.text || '').substring(0, 50)}... ${t.is_approved ? 'Aprobado' : 'Pendiente'}
`).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 => ` ${f.question} ${(f.answer || '').substring(0, 100)}... ${f.is_active ? 'Activo' : 'Inactivo'}
`).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 => ` ${s.title} ${(s.description || '').substring(0, 100)}... ${s.is_active ? 'Activo' : 'Inactivo'}
`).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 => `
${u.name}${u.role}
${u.email} ${roleLabels[u.role] || u.role} ${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'} ${u.is_active ? 'Activo' : 'Inactivo'}
`).join('') if (users.length === 0) tbody.innerHTML = 'No hay usuarios' } 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 = '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}` 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() })