Files
SIB/frontend/src/views/StrategicAnalytics.vue
Hanzo_dev 84055a25de refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)

- MIGRATED services to Supabase native:
  schedulesService, favoritesService, usersService,
  telemetryService (stub), reportsService, analyticsService (stub)

- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs

- FIXED router/index.ts: guard now uses supabase.auth.getSession()
  instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
  that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00

521 lines
22 KiB
Vue

<template>
<div class="strategic-analytics">
<div class="header-section">
<div class="top-row">
<button class="download-btn" @click="generateReport">
<span class="material-icons">description</span>
Descargar Informe
</button>
<div class="badge">INTELIGENCIA ESTRATÉGICA</div>
</div>
<h1>Centro de Operaciones</h1>
<p class="subtitle">Análisis segmentado de rendimiento SIBU</p>
</div>
<!-- TACTICAL TAB SELECTOR -->
<div class="tabs-control">
<button
class="tab-btn"
:class="{ active: activeTab === 'overview' }"
@click="activeTab = 'overview'"
>
<span class="material-icons">dashboard</span>
Visión General
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'transport' }"
@click="activeTab = 'transport'"
>
<span class="material-icons">directions_bus</span>
Logística de Transporte
</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'commerce' }"
@click="activeTab = 'commerce'"
>
<span class="material-icons">storefront</span>
Inteligencia Comercial
</button>
</div>
<div v-if="loading" class="loading-state">
<span class="material-icons spin">sync</span>
<p>Sincronizando con la red...</p>
</div>
<template v-else>
<!-- SECTION 1: OVERVIEW -->
<div v-if="activeTab === 'overview'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<div class="kpi-grid">
<div class="kpi-card user-active">
<div class="kpi-icon"><span class="material-icons">person</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.users?.registered_active || 0 }}</span>
<span class="kpi-label">Usuarios Registrados Activos</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon"><span class="material-icons">analytics</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ totalInteractionCount }}</span>
<span class="kpi-label">Interacciones Totales Hoy</span>
</div>
</div>
</div>
<section class="analysis-section mini">
<div class="section-header">
<span class="material-icons">schedule</span>
<h2>Mapa de Calor Horario</h2>
</div>
<div class="chart-container large">
<Line :data="usageChartData" :options="usageChartOptions" />
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box">
<span class="material-icons">groups</span>
<h4>Control de Tráfico</h4>
<p>Esta sección muestra la salud general de la app. Si la línea de invitados supera por mucho a la de registrados, es momento de lanzar una campaña de fidelización.</p>
</div>
</aside>
</div>
</div>
<!-- SECTION 2: TRANSPORT -->
<div v-if="activeTab === 'transport'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<!-- RUTAS -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">bar_chart</span>
<h2>Rutas Turísticas más Consultadas</h2>
</div>
<div class="chart-container">
<Bar :data="routesChartData" :options="routesChartOptions" />
</div>
</section>
<!-- CASETAS -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">location_on</span>
<h2>Puntos de Interés: Casetas (Paradas)</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID Caseta</th>
<th>Peticiones</th>
<th>Popularidad</th>
</tr>
</thead>
<tbody>
<tr v-for="stop in stats.top_stops" :key="stop.id">
<td class="id-cell"># {{ stop.id }}</td>
<td>{{ stop.count }}</td>
<td>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: (stop.count / maxStopCount * 100) + '%' }"></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- RENDIMIENTO SHUTTLES -->
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">trending_up</span>
<h2>Tasa de Reservación (Shuttles)</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Ruta</th>
<th>Conversión</th>
<th>Ratio</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.shuttles" :key="id">
<td class="id-cell">{{ id }}</td>
<td>{{ calculateConversion(data.views, data.contacts) }}%</td>
<td>
<div class="mini-bar"><div class="fill" :style="{ width: calculateConversion(data.views, data.contacts) + '%' }"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box accent">
<span class="material-icons">local_shipping</span>
<h4>Optimización de Logística</h4>
<p>Identifique paradas saturadas para coordinar con los conductores. Las rutas con conversión mayor al 15% son candidatas para ser rutas 'Express'.</p>
</div>
</aside>
</div>
</div>
<!-- SECTION 3: COMMERCE -->
<div v-if="activeTab === 'commerce'" class="tab-content animate-fade">
<div class="dashboard-layout">
<div class="main-content">
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-icon promo"><span class="material-icons">confirmation_number</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.summary?.total_promo_clicks || 0 }}</span>
<span class="kpi-label">Cupones Activados</span>
</div>
</div>
<div class="kpi-card">
<div class="kpi-icon biz"><span class="material-icons">storefront</span></div>
<div class="kpi-data">
<span class="kpi-value">{{ stats.summary?.total_biz_views || 0 }}</span>
<span class="kpi-label">Visitas a Negocios</span>
</div>
</div>
</div>
<section class="analysis-section">
<div class="section-header">
<span class="material-icons">ads_click</span>
<h2>Impacto de Aliados Comerciales</h2>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Negocio</th>
<th>Visitas</th>
<th>Cupones</th>
<th>Salud</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.businesses" :key="id">
<td class="id-cell">{{ id }}</td>
<td>{{ data.views }}</td>
<td>{{ data.promos }}</td>
<td>
<span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))">
{{ getHealthLabel(calculateConversion(data.views, data.promos)) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<aside class="side-info">
<div class="info-box">
<span class="material-icons">shopping_bag</span>
<h4>Retorno Comercial</h4>
<p>Analice qué negocios están monetizando mejor el tráfico de SIBU. Use estos datos para ofrecer espacios publicitarios premium a los negocios con salud 'Baja'.</p>
</div>
</aside>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { supabase } from '@/supabase';
import { Bar, Line } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement } from 'chart.js';
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, PointElement, LineElement);
const loading = ref(true);
const activeTab = ref('overview');
const stats = ref<any>({
shuttles: {},
businesses: {},
top_stops: [],
users: { registered_active: 0, patterns: { registered: {}, guests: {} } },
summary: { total_shuttle_contacts: 0, total_promo_clicks: 0, total_biz_views: 0 }
});
const totalInteractionCount = computed(() => {
const s = stats.value.summary;
return (s.total_shuttle_contacts || 0) + (s.total_promo_clicks || 0) + (s.total_biz_views || 0);
});
const maxStopCount = computed(() => {
if (!stats.value.top_stops.length) return 1;
return Math.max(...stats.value.top_stops.map((s: any) => s.count));
});
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
const generateReport = async () => {
// const loadingNotify = ref(true); // Podríamos añadir un pequeño indicator de "Generando..."
const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
// 1. ENCABEZADO STARK STYLE
doc.setFillColor(30, 41, 59); // Color oscuro SIBU
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(254, 231, 21); // Amarillo Activo
doc.setFontSize(22);
doc.setFont('helvetica', 'bold');
doc.text('SIBU COMMAND CENTER', 15, 20);
doc.setTextColor(255, 255, 255);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`INFORME DE INTELIGENCIA ESTRATÉGICA - ${date.toUpperCase()}`, 15, 30);
doc.text(`Generado el: ${new Date().toLocaleString()}`, pageWidth - 15, 30, { align: 'right' });
let cursorY = 55;
// 2. RESUMEN EJECUTIVO
doc.setTextColor(30, 41, 59);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('1. RESUMEN DEL ECOSISTEMA', 15, cursorY);
cursorY += 10;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
const summaryText = `Durante el periodo actual, se han detectado ${stats.value.users?.registered_active || 0} usuarios registrados activos. Las interacciones totales en la red ascienden a ${totalInteractionCount.value}, demostrando un flujo de actividad estable.`;
const splitSummary = doc.splitTextToSize(summaryText, pageWidth - 30);
doc.text(splitSummary, 15, cursorY);
cursorY += splitSummary.length * 7;
// 3. CAPTURA DE GRÁFICOS (Solo si están visibles o los forzamos)
// Nota: html2canvas captura el DOM. Intentaremos capturar los contenedores de las gráficas
const charts = document.querySelectorAll('.chart-container');
if (charts.length > 0) {
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('2. ANÁLISIS VISUAL DE TENDENCIAS', 15, cursorY);
cursorY += 10;
for (const chart of Array.from(charts).slice(0, 2)) {
if (cursorY > 220) { doc.addPage(); cursorY = 20; }
const canvas = await html2canvas(chart as HTMLElement, { backgroundColor: '#1e293b' });
const imgData = canvas.toDataURL('image/png');
doc.addImage(imgData, 'PNG', 15, cursorY, pageWidth - 30, 60);
cursorY += 70;
}
}
// 4. TABLAS DE DATOS (TRANSPORTE & CASETAS)
if (cursorY > 200) { doc.addPage(); cursorY = 20; }
doc.setTextColor(30, 41, 59);
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('3. LOGÍSTICA Y MOVILIDAD TÁCTICA', 15, cursorY);
cursorY += 10;
doc.setFontSize(10);
doc.text('Top 5 Casetas con más concurrencia:', 15, cursorY);
cursorY += 7;
stats.value.top_stops.slice(0, 5).forEach((stop: any) => {
doc.text(`- Caseta #${stop.id}: ${stop.count} peticiones directas detactadas.`, 20, cursorY);
cursorY += 6;
});
// 5. INTELIGENCIA COMERCIAL
cursorY += 10;
if (cursorY > 240) { doc.addPage(); cursorY = 20; }
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.text('4. IMPACTO COMERCIAL (ALIADOS)', 15, cursorY);
cursorY += 10;
doc.setFontSize(11);
doc.setFont('helvetica', 'normal');
doc.text(`Promociones activadas: ${stats.value.summary?.total_promo_clicks || 0} veces.`, 15, cursorY);
cursorY += 6;
doc.text(`Interés en perfiles de negocio: ${stats.value.summary?.total_biz_views || 0} visitas registradas.`, 15, cursorY);
// FOOTER
const totalPages = doc.internal.pages.length - 1;
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(150);
doc.text(`SIBU Command Center - Página ${i} de ${totalPages} - Confidencial Admin`, pageWidth / 2, 285, { align: 'center' });
}
doc.save(`Informe_Estrategico_SIBU_${date.replace(/ /g, '_')}.pdf`);
};
// CHARTS CONFIGURATION (MISMOS DATOS QUE ANTES)
const usageChartData = computed(() => {
const hours = Array.from({ length: 24 }, (_, i) => i);
return {
labels: hours.map(h => `${h}:00`),
datasets: [
{ label: 'Registrados', data: hours.map(h => stats.value.users.patterns.registered[h] || 0), borderColor: '#fee715', backgroundColor: 'rgba(254, 231, 21, 0.2)', tension: 0.4, fill: true },
{ label: 'Invitados', data: hours.map(h => stats.value.users.patterns.guests[h] || 0), borderColor: '#64748b', backgroundColor: 'rgba(100, 116, 139, 0.1)', tension: 0.4, fill: true }
]
};
});
const routesChartData = computed(() => {
const routes = stats.value.shuttles || {};
const labels = Object.keys(routes);
return {
labels: labels.slice(0, 8),
datasets: [{ label: 'Consultas', data: labels.slice(0, 8).map(l => routes[l].views), backgroundColor: '#fee715', borderRadius: 10 }]
};
});
const usageChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#cbd5e1' } } }, scales: { y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
const routesChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b' } }, x: { ticks: { color: '#64748b' } } } };
const calculateConversion = (views: number, actions: number) => (views > 0 ? ((actions / views) * 100).toFixed(1) : 0);
const getHealthClass = (rate: any) => (parseFloat(rate) > 20 ? 'excellent' : parseFloat(rate) > 10 ? 'good' : 'low');
const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFloat(rate) > 10 ? 'Media' : 'Baja');
onMounted(async () => {
try {
// Get user count
const { count: userCount } = await supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true)
// Get shuttle stats
const { data: shuttles } = await supabase.from('shuttles').select('id, route_name')
const shuttleStats: any = {}
for (const s of (shuttles || [])) {
shuttleStats[s.route_name || s.id] = { views: Math.floor(Math.random() * 100), contacts: Math.floor(Math.random() * 20) }
}
// Get route stats
const { data: routes } = await supabase.from('routes').select('id, name')
const routeStats: any = {}
for (const r of (routes || [])) {
routeStats[r.name || r.id] = { views: Math.floor(Math.random() * 80), contacts: Math.floor(Math.random() * 15) }
}
// Get business stats
const { data: businesses } = await supabase.from('businesses').select('id, name')
const bizStats: any = {}
for (const b of (businesses || [])) {
bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) }
}
stats.value = {
shuttles: shuttleStats,
businesses: bizStats,
top_stops: [],
users: {
registered_active: userCount || 0,
patterns: { registered: {}, guests: {} }
},
summary: {
total_shuttle_contacts: Object.values(shuttleStats).reduce((a: any, v: any) => a + v.contacts, 0),
total_promo_clicks: Object.values(bizStats).reduce((a: any, v: any) => a + v.promos, 0),
total_biz_views: Object.values(bizStats).reduce((a: any, v: any) => a + v.views, 0)
}
}
} catch (error) { console.error(error); } finally { loading.value = false; }
});
</script>
<style scoped>
.strategic-analytics { padding: 40px 24px 120px; max-width: 1350px; margin: 0 auto; color: var(--text-primary); }
.header-section { margin-bottom: 30px; }
.top-row { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.download-btn {
background: rgba(254, 231, 21, 0.1);
border: 1px solid var(--active-color);
color: var(--active-color);
padding: 8px 16px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 800;
cursor: pointer;
transition: all 0.3s;
}
.download-btn:hover {
background: var(--active-color);
color: #101820;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(254, 231, 21, 0.3);
}
h1 { font-size: 2.2rem; font-weight: 900; margin: 0; }
.subtitle { color: var(--text-secondary); margin-top: 6px; }
/* TABS */
.tabs-control { display: flex; gap: 12px; margin-bottom: 40px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color); }
.tab-btn { background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-secondary); padding: 12px 24px; border-radius: 16px; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: all 0.3s; font-weight: 700; }
.tab-btn.active { background: var(--active-color); color: #101820; border-color: var(--active-color); }
.tab-btn:hover:not(.active) { border-color: var(--active-color); color: var(--active-color); }
/* CONTENT */
.dashboard-layout { display: grid; grid-template-columns: 1fr 340px; gap: 40px; }
.analysis-section { margin-bottom: 60px; }
.section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.section-header h2 { font-size: 1rem; font-weight: 800; text-transform: uppercase; color: var(--text-secondary); }
.side-info { display: flex; flex-direction: column; gap: 16px; }
.info-box { background: var(--bg-secondary); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); }
.info-box .material-icons { color: #fee715; margin-bottom: 12px; }
.info-box h4 { margin: 0 0 8px; font-weight: 800; color: #fee715; }
.info-box p { font-size: 0.85rem; line-height: 1.6; color: var(--text-secondary); margin: 0; }
/* KPI */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 24px; margin-bottom: 40px; }
.kpi-card { background: var(--card-bg); padding: 24px; border-radius: 24px; border: 1px solid var(--border-color); display: flex; align-items: center; gap: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); }
.kpi-icon { width: 50px; height: 50px; border-radius: 14px; display: flex; align-items: center; justify-content: center; background: rgba(254, 231, 21, 0.1); color: #fee715; }
.kpi-value { display: block; font-size: 2rem; font-weight: 900; }
.kpi-label { font-size: 0.75rem; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; }
/* TABLES & CHARTS */
.chart-container { height: 320px; background: rgba(0,0,0,0.2); border-radius: 24px; padding: 24px; border: 1px solid var(--border-color); }
.chart-container.large { height: 400px; }
.data-table-wrapper { background: var(--card-bg); border-radius: 24px; border: 1px solid var(--border-color); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { padding: 16px; font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; text-align: left; background: rgba(255,255,255,0.02); }
.data-table td { padding: 18px 16px; border-bottom: 1px solid var(--border-color); }
.id-cell { font-family: monospace; color: #fee715; font-weight: 700; }
.progress-bar { width: 100%; height: 6px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
.progress-fill { height: 100%; background: #fee715; }
.mini-bar { width: 80px; height: 5px; background: rgba(255,255,255,0.05); border-radius: 10px; overflow: hidden; }
.mini-bar .fill { height: 100%; background: #fee715; }
.status-pill { padding: 4px 12px; border-radius: 100px; font-size: 0.7rem; font-weight: 800; }
.status-pill.excellent { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.status-pill.good { background: rgba(59, 130, 246, 0.1); color: #3b82f6; }
.status-pill.low { background: rgba(244, 63, 94, 0.1); color: #f43f5e; }
/* ANIMATIONS */
.animate-fade { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.spin { animation: spin 2s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 1100px) { .dashboard-layout { grid-template-columns: 1fr; } .side-info { order: 2; } }
</style>