// 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 `
${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) { 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 = '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 = '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 => `
${t.name}
${t.location || '-'} ${'★'.repeat(t.rating)}${'☆'.repeat(5 - t.rating)} ${(t.text || '').substring(0, 50)}... ${t.is_approved ? 'Aprobado' : 'Pendiente'}
`).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 => ` ${f.question} ${(f.answer || '').substring(0, 100)}... ${f.is_active ? 'Activo' : 'Inactivo'}
`).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 = '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 = '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 => ` ${s.title} ${(s.description || '').substring(0, 100)}... ${s.is_active ? 'Activo' : 'Inactivo'}
`).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 = '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 = '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 => `
${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() })