feat: optimización integral y auditoría de rendimiento SIBU 2.0.1
This commit is contained in:
@ -17,9 +17,7 @@ const favoritesStore = useFavoritesStore()
|
|||||||
|
|
||||||
const isSplashScreen = computed(() => route.name === 'splash')
|
const isSplashScreen = computed(() => route.name === 'splash')
|
||||||
const isAuthScreen = computed(() => {
|
const isAuthScreen = computed(() => {
|
||||||
const name = route.name?.toString().toLowerCase() || ''
|
return route.path === '/login' || route.path === '/register' || route.name === 'auth'
|
||||||
const path = route.path.toLowerCase()
|
|
||||||
return name.includes('auth') || path.includes('/login') || path.includes('/register')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
132
frontend/src/components/map/ArrivalBanner.vue
Normal file
132
frontend/src/components/map/ArrivalBanner.vue
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="banner-slide">
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
class="best-stop-banner-compact"
|
||||||
|
>
|
||||||
|
<div class="banner-icon-bg">
|
||||||
|
<span class="material-icons text-white text-[16px]">directions_bus</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col flex-1 truncate ml-2" style="min-width: 0;">
|
||||||
|
<span class="text-[9px] uppercase font-bold text-gray-500 dark:text-gray-400 leading-none">{{ t('map.arrivalTime') }}</span>
|
||||||
|
<span class="trigger-text-compact truncate leading-tight">{{ stopName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="eta-badge">
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<div class="eta-loader"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="hasActiveBuses">
|
||||||
|
<span class="eta-value">{{ etaValue }}</span>
|
||||||
|
<span class="eta-unit">min</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="eta-unit">-- min</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click.stop="$emit('close')" class="ml-2 p-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<span class="material-icons text-[18px] text-gray-400 hover:text-red-500">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isVisible: boolean
|
||||||
|
stopName: string
|
||||||
|
isLoading: boolean
|
||||||
|
hasActiveBuses: boolean
|
||||||
|
etaValue: number | string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['close'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.best-stop-banner-compact {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 1200;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-icon-bg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--active-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-text-compact {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eta-badge {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eta-value {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eta-unit {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eta-loader {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(254, 231, 21, 0.2);
|
||||||
|
border-top-color: var(--active-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.banner-slide-enter-active {
|
||||||
|
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.banner-slide-leave-active {
|
||||||
|
transition: all 0.4s cubic-bezier(0.7, 0, 0.84, 0);
|
||||||
|
}
|
||||||
|
.banner-slide-enter-from, .banner-slide-leave-to {
|
||||||
|
transform: translateY(-100%) scale(0.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
268
frontend/src/components/map/PromoCarousel.vue
Normal file
268
frontend/src/components/map/PromoCarousel.vue
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="sheet-slide">
|
||||||
|
<div v-if="isOpen && coupons.length > 0" class="offers-sheet">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="sheet-header">
|
||||||
|
<div class="sheet-title-group">
|
||||||
|
<span class="sheet-title">{{ t('coupons.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="sheet-close" @click="$emit('close')">
|
||||||
|
<span class="material-icons">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card area with nav arrows -->
|
||||||
|
<div class="sheet-card-area">
|
||||||
|
<button class="sheet-nav" @click="$emit('prev')" :disabled="coupons.length < 2">
|
||||||
|
<span class="material-icons">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="carousel-slide" mode="out-in">
|
||||||
|
<div
|
||||||
|
v-if="currentPromo"
|
||||||
|
:key="currentPromo.id"
|
||||||
|
class="sheet-card"
|
||||||
|
:style="{ backgroundImage: `url(${getImageUrl(currentPromo.image_url, 'coupon')})` }"
|
||||||
|
@mouseenter="$emit('pause')"
|
||||||
|
@touchstart="$emit('pause')"
|
||||||
|
@mouseleave="$emit('resume')"
|
||||||
|
>
|
||||||
|
<div class="sheet-card-overlay">
|
||||||
|
<div class="sheet-info">
|
||||||
|
<span class="sheet-biz-name">{{ currentPromo.business?.name || 'Local' }}</span>
|
||||||
|
<h3 class="sheet-promo-title">{{ currentPromo.title }}</h3>
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<button class="sheet-cta" @click="$emit('promo-click', currentPromo)">{{ t('coupons.viewDetails') }}</button>
|
||||||
|
<span v-if="currentPromo.discount_percentage" class="sheet-discount-tag">-{{ currentPromo.discount_percentage }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button class="sheet-nav" @click="$emit('next')" :disabled="coupons.length < 2">
|
||||||
|
<span class="material-icons">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dots -->
|
||||||
|
<div class="sheet-dots" v-if="coupons.length > 1">
|
||||||
|
<div
|
||||||
|
v-for="(_, i) in coupons"
|
||||||
|
:key="i"
|
||||||
|
class="sheet-dot"
|
||||||
|
:class="{ 'sheet-dot--active': i === currentIndex }"
|
||||||
|
@click="$emit('update:index', i)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { getImageUrl } from '@/utils/imageUrl'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
coupons: any[]
|
||||||
|
currentIndex: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['close', 'prev', 'next', 'pause', 'resume', 'promo-click', 'update:index'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const currentPromo = computed(() => {
|
||||||
|
if (props.coupons.length === 0) return null
|
||||||
|
const idx = props.currentIndex % props.coupons.length
|
||||||
|
return props.coupons[idx]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.offers-sheet {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
max-width: 420px;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
z-index: 3000;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
color: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.6s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.offers-sheet {
|
||||||
|
background: rgba(20, 20, 20, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #101820;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.sheet-title { color: #FFFFFF; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-card-area {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #101820;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-nav:first-of-type { left: 12px; }
|
||||||
|
.sheet-nav:last-of-type { right: 12px; }
|
||||||
|
.sheet-nav:disabled { opacity: 0.1; }
|
||||||
|
|
||||||
|
.sheet-card {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-card-overlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0,0,0,0.5) 0%,
|
||||||
|
rgba(0,0,0,0) 30%,
|
||||||
|
rgba(0,0,0,0) 60%,
|
||||||
|
rgba(0,0,0,0.85) 100%
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-biz-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fee715;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-promo-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-cta {
|
||||||
|
background: var(--active-color);
|
||||||
|
color: #101820;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-discount-tag {
|
||||||
|
background: #f43f5e;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.25rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-dot--active {
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.sheet-slide-enter-active, .sheet-slide-leave-active { transition: all 0.5s ease; }
|
||||||
|
.sheet-slide-enter-from, .sheet-slide-leave-to { transform: translate(-50%, 100%); opacity: 0; }
|
||||||
|
|
||||||
|
.carousel-slide-enter-active, .carousel-slide-leave-active { transition: all 0.5s ease; }
|
||||||
|
.carousel-slide-enter-from { opacity: 0; transform: translateX(40px); }
|
||||||
|
.carousel-slide-leave-to { opacity: 0; transform: translateX(-40px); }
|
||||||
|
</style>
|
||||||
233
frontend/src/components/map/SearchOverlay.vue
Normal file
233
frontend/src/components/map/SearchOverlay.vue
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<div class="uber-search-container" :class="{ 'compact-mode': isCompact }">
|
||||||
|
<!-- Floating Triggers -->
|
||||||
|
<div v-if="!showPanel" class="triggers-row">
|
||||||
|
<!-- Shrunk Trigger (Icon only) -->
|
||||||
|
<div
|
||||||
|
v-if="isRouteActive"
|
||||||
|
class="uber-search-trigger circular"
|
||||||
|
@click="$emit('open')"
|
||||||
|
:title="t('map.search')"
|
||||||
|
>
|
||||||
|
<span class="material-icons">search</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal Trigger -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="uber-search-trigger-compact"
|
||||||
|
@click="$emit('open')"
|
||||||
|
>
|
||||||
|
<span class="material-icons search-icon">directions_bus</span>
|
||||||
|
<span class="trigger-label">{{ t('map.viewRoutes') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot for additional triggers like ArrivalBanner -->
|
||||||
|
<slot name="extra-triggers"></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uber-style Search Panel -->
|
||||||
|
<Transition name="uber-slide">
|
||||||
|
<div v-if="showPanel" class="uber-search-panel">
|
||||||
|
<div class="uber-search-header">
|
||||||
|
<button class="back-btn" @click="$emit('close')">
|
||||||
|
<span class="material-icons">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<div class="search-title">{{ t('map.availableRoutes') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="uber-results custom-scrollbar">
|
||||||
|
<div
|
||||||
|
v-for="route in allRoutes"
|
||||||
|
:key="route.id"
|
||||||
|
class="uber-result-item"
|
||||||
|
:class="{ 'selected-route': route.id === selectedRouteId && wasSelectedFromMap }"
|
||||||
|
@click="$emit('select-route', route)"
|
||||||
|
>
|
||||||
|
<div class="result-icon">
|
||||||
|
<span class="material-icons">directions_bus</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-name">{{ route.name }}</div>
|
||||||
|
<div class="result-address">{{ t('map.busRoute') }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="material-icons check-icon">
|
||||||
|
{{ route.id === selectedRouteId ? 'check_circle' : 'chevron_right' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
showPanel: boolean
|
||||||
|
isCompact: boolean
|
||||||
|
isRouteActive: boolean
|
||||||
|
allRoutes: any[]
|
||||||
|
selectedRouteId: string | null
|
||||||
|
wasSelectedFromMap: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['open', 'close', 'select-route'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.uber-search-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 90px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-container > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-trigger {
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-trigger-compact {
|
||||||
|
background: var(--active-color) !important;
|
||||||
|
color: #101820 !important;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-trigger.circular {
|
||||||
|
width: 44px;
|
||||||
|
padding: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggers-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-panel {
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-search-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-results {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-result-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-route {
|
||||||
|
background: rgba(254, 231, 21, 0.1);
|
||||||
|
border: 1px solid var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-address {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: var(--active-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uber-slide-enter-active, .uber-slide-leave-active { transition: all 0.5s ease; }
|
||||||
|
.uber-slide-enter-from, .uber-slide-leave-to { transform: translateY(20px); opacity: 0; }
|
||||||
|
</style>
|
||||||
@ -177,8 +177,14 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar sesión activa
|
// Verificar sesión activa con seguridad
|
||||||
const { data: { session } } = await supabase.auth.getSession()
|
let session = null
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.getSession()
|
||||||
|
session = data.session
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SIBU | Auth Check Error:', e)
|
||||||
|
}
|
||||||
|
|
||||||
// Sin sesión en ruta protegida → login
|
// Sin sesión en ruta protegida → login
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const useBusStopStore = defineStore('busStop', () => {
|
|||||||
const CACHE_TIME = 1000 * 60 * 30; // 30 minutos
|
const CACHE_TIME = 1000 * 60 * 30; // 30 minutos
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (isLoading.value) return;
|
||||||
if (!force && busStops.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) {
|
if (!force && busStops.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -33,6 +34,7 @@ export const useBusStopStore = defineStore('busStop', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadBusStopById(id: string, force = false) {
|
async function loadBusStopById(id: string, force = false) {
|
||||||
|
if (isLoading.value) return;
|
||||||
// Buscar en cache primero
|
// Buscar en cache primero
|
||||||
if (!force && busStops.value.length > 0) {
|
if (!force && busStops.value.length > 0) {
|
||||||
const cachedStop = busStops.value.find(s => s.id === id);
|
const cachedStop = busStops.value.find(s => s.id === id);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const useCouponStore = defineStore('coupon', () => {
|
|||||||
const filters = ref<CouponFilters>({})
|
const filters = ref<CouponFilters>({})
|
||||||
|
|
||||||
async function loadCoupons(newFilters?: CouponFilters) {
|
async function loadCoupons(newFilters?: CouponFilters) {
|
||||||
|
if (isLoading.value) return;
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
if (newFilters) {
|
if (newFilters) {
|
||||||
|
|||||||
@ -22,6 +22,9 @@ export const useRouteStore = defineStore('route', () => {
|
|||||||
const CACHE_TIME = 1000 * 60 * 15; // 15 minutos
|
const CACHE_TIME = 1000 * 60 * 15; // 15 minutos
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Guard: Si ya se están cargando rutas, no iniciar otra petición
|
||||||
|
if (isLoadingRoutes.value) return;
|
||||||
|
|
||||||
// Si no forzamos, no hay filtros raros, ya tenemos rutas y aún no expira el caché, omitir llamada
|
// Si no forzamos, no hay filtros raros, ya tenemos rutas y aún no expira el caché, omitir llamada
|
||||||
if (!force && !filters && allRoutes.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) {
|
if (!force && !filters && allRoutes.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) {
|
||||||
return
|
return
|
||||||
@ -44,6 +47,7 @@ export const useRouteStore = defineStore('route', () => {
|
|||||||
const CACHE_TIME = 1000 * 60 * 15; // 15 minutos
|
const CACHE_TIME = 1000 * 60 * 15; // 15 minutos
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (isLoadingStops.value) return;
|
||||||
if (!force && stopsCache.value.has(routeId)) {
|
if (!force && stopsCache.value.has(routeId)) {
|
||||||
const cacheEntry = stopsCache.value.get(routeId)!;
|
const cacheEntry = stopsCache.value.get(routeId)!;
|
||||||
if (now - cacheEntry.fetchedAt < CACHE_TIME) {
|
if (now - cacheEntry.fetchedAt < CACHE_TIME) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
export const SUPABASE_URL = 'https://bjgixlugjzsccazdfmph.supabase.co'
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL
|
||||||
export const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJqZ2l4bHVnanpzY2NhemRmbXBoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIwNjQyMTAsImV4cCI6MjA4NzY0MDIxMH0.untLQoPi4yUr3cPnxo23wYSlg6xnNK0daKu9UHmFTp8'
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
// SIBU | Hybrid Storage: Maneja persistencia según la voluntad del usuario
|
// SIBU | Hybrid Storage: Maneja persistencia según la voluntad del usuario
|
||||||
const authStorage = {
|
const authStorage = {
|
||||||
|
|||||||
39
frontend/src/utils/geo.ts
Normal file
39
frontend/src/utils/geo.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for geographical calculations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the Haversine distance between two points
|
||||||
|
*/
|
||||||
|
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371e3; // Earth radius in meters
|
||||||
|
const f1 = lat1 * Math.PI / 180;
|
||||||
|
const f2 = lat2 * Math.PI / 180;
|
||||||
|
const df = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dl = (lon2 - lon1) * Math.PI / 180;
|
||||||
|
|
||||||
|
const a = Math.sin(df / 2) * Math.sin(df / 2) +
|
||||||
|
Math.cos(f1) * Math.cos(f2) *
|
||||||
|
Math.sin(dl / 2) * Math.sin(dl / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c; // Distance in meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats distance in meters to a human-readable string
|
||||||
|
*/
|
||||||
|
export function formatDistance(meters: number): string {
|
||||||
|
if (meters < 1000) {
|
||||||
|
return `${Math.round(meters)}m`;
|
||||||
|
}
|
||||||
|
return `${(meters / 1000).toFixed(1)}km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimates walking time in minutes
|
||||||
|
*/
|
||||||
|
export function estimateWalkingTime(meters: number): number {
|
||||||
|
const walkingSpeed = 1.4; // 1.4 m/s approx
|
||||||
|
return Math.ceil(meters / walkingSpeed / 60);
|
||||||
|
}
|
||||||
@ -1,521 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="admin-dashboard">
|
|
||||||
<div class="dashboard-header">
|
|
||||||
<div class="header-main">
|
|
||||||
<button class="back-btn" @click="router.push('/admin')">
|
|
||||||
<span class="material-icons">arrow_back</span>
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1>Análisis Estratégico</h1>
|
|
||||||
<p class="subtitle">Métricas en tiempo real del ecosistema SIBU</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<div v-if="lastRefreshed" class="last-sync">
|
|
||||||
Actualizado: {{ lastRefreshed }}
|
|
||||||
</div>
|
|
||||||
<button class="refresh-action" @click="loadStats" :disabled="isLoading">
|
|
||||||
<span class="material-icons" :class="{ 'spin': isLoading }">refresh</span>
|
|
||||||
{{ isLoading ? 'Sincronizando...' : 'Refrescar' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isLoading && !stats.total_events" class="loading-overlay">
|
|
||||||
<div class="loader-content">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Procesando datos...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="dashboard-grid">
|
|
||||||
<!-- Top Overview Metrics -->
|
|
||||||
<div class="metrics-row">
|
|
||||||
<div class="metric-card primary">
|
|
||||||
<div class="card-icon"><span class="material-icons">analytics</span></div>
|
|
||||||
<div class="card-info">
|
|
||||||
<label>Eventos Totales</label>
|
|
||||||
<h3>{{ stats.total_events?.toLocaleString() || '0' }}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-icon"><span class="material-icons">speed</span></div>
|
|
||||||
<div class="card-info">
|
|
||||||
<label>Pico de Actividad</label>
|
|
||||||
<h3>{{ stats.peak_hours?.[0]?.hour || '--' }}:00</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="card-icon"><span class="material-icons">visibility</span></div>
|
|
||||||
<div class="card-info">
|
|
||||||
<label>Pantalla Principal</label>
|
|
||||||
<h3>{{ stats.screen_activity?.[0]?.name || 'Mapa' }}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Charts Rows -->
|
|
||||||
<div class="stats-row">
|
|
||||||
<div class="chart-box flex-66">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Tendencia de Uso (Últimos 7 días)</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<Line v-if="trendChartData" :data="trendChartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-box flex-33">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Distribución por Idioma</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container circle">
|
|
||||||
<Doughnut v-if="langChartData" :data="langChartData" :options="doughnutOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-row">
|
|
||||||
<div class="chart-box flex-50">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Popularidad de Rutas</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<Bar v-if="routesChartData" :data="routesChartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-box flex-50">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Visualizaciones de Promociones</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<Bar v-if="promosChartData" :data="promosChartData" :options="horizontalBarOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-row">
|
|
||||||
<div class="chart-box flex-50">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Top Acciones (Taxis/Otros)</h3>
|
|
||||||
</div>
|
|
||||||
<div class="plain-list">
|
|
||||||
<div v-for="(taxi, idx) in stats.top_taxis" :key="idx" class="list-row">
|
|
||||||
<span class="rank">{{ Number(idx) + 1 }}</span>
|
|
||||||
<span class="label">{{ taxi.id || 'N/A' }}</span>
|
|
||||||
<span class="val">{{ taxi.count }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="!stats.top_taxis?.length" class="empty">Sin registros</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chart-box flex-50">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Top Paradas</h3>
|
|
||||||
</div>
|
|
||||||
<div class="plain-list">
|
|
||||||
<div v-for="(stop, idx) in stats.top_stops" :key="idx" class="list-row">
|
|
||||||
<span class="rank">{{ Number(idx) + 1 }}</span>
|
|
||||||
<span class="label">{{ stop.id || 'N/A' }}</span>
|
|
||||||
<span class="val">{{ stop.count }}</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="!stats.top_stops?.length" class="empty">Sin registros</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stats-row">
|
|
||||||
<div class="chart-box flex-100">
|
|
||||||
<div class="box-header">
|
|
||||||
<h3>Actividad por Hora</h3>
|
|
||||||
</div>
|
|
||||||
<div class="chart-container">
|
|
||||||
<Bar v-if="hoursChartData" :data="hoursChartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
const router = useRouter()
|
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
BarElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ArcElement
|
|
||||||
} from 'chart.js'
|
|
||||||
import { Line, Bar, Doughnut } from 'vue-chartjs'
|
|
||||||
|
|
||||||
// Register ChartJS
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
BarElement,
|
|
||||||
ArcElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend
|
|
||||||
)
|
|
||||||
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const stats = ref<any>({})
|
|
||||||
const lastRefreshed = ref('')
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
stats.value = await analyticsService.getDashboardStats() || {}
|
|
||||||
lastRefreshed.value = new Date().toLocaleTimeString()
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading stats')
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart Configurations
|
|
||||||
const chartOptions = computed<any>(() => ({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
||||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: { display: false },
|
|
||||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const horizontalBarOptions = computed<any>(() => ({
|
|
||||||
indexAxis: 'y' as const,
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
|
||||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
grid: { display: false },
|
|
||||||
ticks: { color: 'rgba(255, 255, 255, 0.5)' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const doughnutOptions = computed<any>(() => ({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'bottom',
|
|
||||||
labels: { color: '#fff', padding: 20 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Computed Chart Data
|
|
||||||
const trendChartData = computed<any>(() => {
|
|
||||||
if (!stats.value.daily_trends?.length) return null
|
|
||||||
return {
|
|
||||||
labels: stats.value.daily_trends.map((t: any) => t.date.split('-').slice(1).join('/')),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Eventos',
|
|
||||||
data: stats.value.daily_trends.map((t: any) => t.count),
|
|
||||||
borderColor: '#fee715',
|
|
||||||
backgroundColor: 'rgba(254, 231, 21, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const langChartData = computed<any>(() => {
|
|
||||||
if (!stats.value.languages?.length) return null
|
|
||||||
return {
|
|
||||||
labels: stats.value.languages.map((l: any) => l.id === 'es' ? 'Español' : 'English'),
|
|
||||||
datasets: [{
|
|
||||||
data: stats.value.languages.map((l: any) => l.count),
|
|
||||||
backgroundColor: ['#fee715', '#64748b'],
|
|
||||||
borderWidth: 0
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const promosChartData = computed<any>(() => {
|
|
||||||
if (!stats.value.top_promos?.length) return null
|
|
||||||
return {
|
|
||||||
labels: stats.value.top_promos.map((p: any) => p.id),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Visualizaciones',
|
|
||||||
data: stats.value.top_promos.map((p: any) => p.count),
|
|
||||||
backgroundColor: '#fee715',
|
|
||||||
borderRadius: 6
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const routesChartData = computed<any>(() => {
|
|
||||||
if (!stats.value.top_routes?.length) return null
|
|
||||||
return {
|
|
||||||
labels: stats.value.top_routes.map((r: any) => r.id),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Consultas',
|
|
||||||
data: stats.value.top_routes.map((r: any) => r.count),
|
|
||||||
backgroundColor: '#fee715',
|
|
||||||
borderRadius: 8
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const hoursChartData = computed<any>(() => {
|
|
||||||
if (!stats.value.peak_hours?.length) return null
|
|
||||||
// Sort by hour
|
|
||||||
const sorted = [...stats.value.peak_hours].sort((a,b) => a.hour - b.hour)
|
|
||||||
return {
|
|
||||||
labels: sorted.map(h => `${h.hour}h`),
|
|
||||||
datasets: [{
|
|
||||||
label: 'Actividad',
|
|
||||||
data: sorted.map(h => h.count),
|
|
||||||
backgroundColor: 'rgba(254, 231, 21, 0.2)',
|
|
||||||
hoverBackgroundColor: '#fee715',
|
|
||||||
borderRadius: 4
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(loadStats)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-dashboard {
|
|
||||||
padding: 32px 24px;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding-bottom: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
|
||||||
background: var(--hover-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn:hover { background: var(--active-color); color: #101820; transform: scale(1.05); }
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2rem, 5vw, 2.5rem);
|
|
||||||
font-weight: 900;
|
|
||||||
margin: 0;
|
|
||||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle { color: var(--text-secondary); margin: 6px 0 0 0; font-weight: 500; }
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.last-sync { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
|
|
||||||
|
|
||||||
.refresh-action {
|
|
||||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
|
||||||
color: #101820;
|
|
||||||
border: none;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 14px;
|
|
||||||
font-weight: 900;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
box-shadow: 0 10px 20px rgba(254, 231, 21, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-action:hover { transform: translateY(-2px); box-shadow: 0 15px 30px rgba(254, 231, 21, 0.3); }
|
|
||||||
.refresh-action:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.spin { animation: rotation 1s infinite linear; }
|
|
||||||
@keyframes rotation { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* Metrics */
|
|
||||||
.metrics-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
padding: 28px;
|
|
||||||
border-radius: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card:hover { transform: translateY(-4px); border-color: var(--active-color); }
|
|
||||||
|
|
||||||
.metric-card.primary {
|
|
||||||
background: linear-gradient(135deg, #fee715 0%, #facc15 100%);
|
|
||||||
color: #101820;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border-radius: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card.primary .card-icon { background: rgba(0,0,0,0.1); }
|
|
||||||
|
|
||||||
.card-icon .material-icons { font-size: 32px; color: var(--active-color); }
|
|
||||||
.metric-card.primary .card-icon .material-icons { color: #101820; }
|
|
||||||
|
|
||||||
.card-info label { display: block; font-size: 13px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.1em; font-weight: 800; margin-bottom: 4px; }
|
|
||||||
.metric-card.primary .card-info label { color: #101820; opacity: 0.7; }
|
|
||||||
.card-info h3 { font-size: 28px; font-weight: 900; margin: 0; letter-spacing: -0.02em; }
|
|
||||||
|
|
||||||
/* Charts */
|
|
||||||
.dashboard-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-box {
|
|
||||||
background: var(--card-bg);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-radius: 32px;
|
|
||||||
padding: 32px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.box-header { margin-bottom: 32px; }
|
|
||||||
.box-header h3 { font-size: 1.25rem; font-weight: 900; color: var(--text-primary); display: flex; align-items: center; gap: 12px; letter-spacing: -0.02em; }
|
|
||||||
|
|
||||||
.chart-container { height: 320px; position: relative; }
|
|
||||||
.chart-container.circle { height: 280px; }
|
|
||||||
|
|
||||||
.stats-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-33 { flex: 1; }
|
|
||||||
.flex-50 { flex: 1; }
|
|
||||||
.flex-66 { flex: 2; }
|
|
||||||
.flex-100 { flex: 1; width: 100%; }
|
|
||||||
|
|
||||||
/* Plain Lists */
|
|
||||||
.plain-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-row:hover { transform: translateX(8px); border-color: var(--active-color); }
|
|
||||||
|
|
||||||
.rank { width: 32px; color: var(--text-secondary); font-weight: 900; font-size: 14px; }
|
|
||||||
.label { flex: 1; font-weight: 700; color: var(--text-primary); }
|
|
||||||
.val { font-weight: 900; color: var(--active-color); font-size: 1.1rem; }
|
|
||||||
|
|
||||||
.empty { text-align: center; color: var(--text-secondary); padding: 48px; font-weight: 600; }
|
|
||||||
|
|
||||||
.loading-overlay {
|
|
||||||
height: 60vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-content { text-align: center; }
|
|
||||||
.spinner {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border: 4px solid var(--border-color);
|
|
||||||
border-top-color: var(--active-color);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: rotation 1s infinite linear;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.stats-row { flex-direction: column; }
|
|
||||||
.metrics-row { grid-template-columns: 1fr; }
|
|
||||||
.dashboard-header { flex-direction: column; align-items: flex-start; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -562,7 +562,7 @@ function resetFilters() {
|
|||||||
border-radius: 1.125rem;
|
border-radius: 1.125rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
aspect-ratio: 1/1;
|
aspect-ratio: 16/9;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
@ -660,7 +660,7 @@ function resetFilters() {
|
|||||||
|
|
||||||
.biz-img-wrap {
|
.biz-img-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 16/10;
|
aspect-ratio: 4/3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -710,9 +710,9 @@ function resetFilters() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.biz-area-icon { font-size: 0.875rem; }
|
.biz-area-icon { font-size: 0.875rem; }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -263,11 +263,13 @@ const maxStopCount = computed(() => {
|
|||||||
return Math.max(...stats.value.top_stops.map((s: any) => s.count));
|
return Math.max(...stats.value.top_stops.map((s: any) => s.count));
|
||||||
});
|
});
|
||||||
|
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import html2canvas from 'html2canvas';
|
|
||||||
|
|
||||||
const generateReport = async () => {
|
const generateReport = async () => {
|
||||||
// const loadingNotify = ref(true); // Podríamos añadir un pequeño indicator de "Generando..."
|
// OPTIMIZACIÓN: Carga dinámica de librerías pesadas para no afectar el rendimiento inicial
|
||||||
|
const [{ jsPDF }, html2canvas] = await Promise.all([
|
||||||
|
import('jspdf'),
|
||||||
|
import('html2canvas').then(m => m.default)
|
||||||
|
]);
|
||||||
|
|
||||||
const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
const date = new Date().toLocaleDateString('es-ES', { month: 'long', year: 'numeric' });
|
||||||
const doc = new jsPDF('p', 'mm', 'a4');
|
const doc = new jsPDF('p', 'mm', 'a4');
|
||||||
const pageWidth = doc.internal.pageSize.getWidth();
|
const pageWidth = doc.internal.pageSize.getWidth();
|
||||||
@ -393,25 +395,29 @@ const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFlo
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Get user count
|
// Load all data in parallel
|
||||||
const { count: userCount } = await supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true)
|
const [
|
||||||
|
{ count: userCount },
|
||||||
|
{ data: shuttles },
|
||||||
|
{ data: routes },
|
||||||
|
{ data: businesses }
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true),
|
||||||
|
supabase.from('shuttles').select('id, route_name'),
|
||||||
|
supabase.from('routes').select('id, name'),
|
||||||
|
supabase.from('businesses').select('id, name')
|
||||||
|
])
|
||||||
|
|
||||||
// Get shuttle stats
|
|
||||||
const { data: shuttles } = await supabase.from('shuttles').select('id, route_name')
|
|
||||||
const shuttleStats: any = {}
|
const shuttleStats: any = {}
|
||||||
for (const s of (shuttles || [])) {
|
for (const s of (shuttles || [])) {
|
||||||
shuttleStats[s.route_name || s.id] = { views: Math.floor(Math.random() * 100), contacts: Math.floor(Math.random() * 20) }
|
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 = {}
|
const routeStats: any = {}
|
||||||
for (const r of (routes || [])) {
|
for (const r of (routes || [])) {
|
||||||
routeStats[r.name || r.id] = { views: Math.floor(Math.random() * 80), contacts: Math.floor(Math.random() * 15) }
|
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 = {}
|
const bizStats: any = {}
|
||||||
for (const b of (businesses || [])) {
|
for (const b of (businesses || [])) {
|
||||||
bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) }
|
bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) }
|
||||||
|
|||||||
Reference in New Issue
Block a user