#49 fix: marcador de producto sin imagen visible en el mapa de provenance

This commit is contained in:
2026-08-16 04:10:39 -05:00
parent 6200fa7432
commit 18026617ce
7 changed files with 752 additions and 2 deletions

View File

@@ -0,0 +1,248 @@
<template>
<div>
<v-alert
v-if="!hasMarkers"
class="my-4"
type="info"
variant="tonal"
>
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert>
<template v-else>
<div ref="mapEl" class="map-wrapper" />
<v-alert
v-if="!storePosition"
class="mt-2"
type="warning"
variant="tonal"
>
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
</v-alert>
<p class="text-caption text-medium-emphasis mt-2 mb-0">
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda.
</p>
</template>
<ProvenanceRelationModal
:product="selectedProduct"
:relation="selectedRelation"
:visible="modalVisible"
@update:visible="modalVisible = $event"
/>
</div>
</template>
<script setup>
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { useSettingsStore } from '@/stores/settings'
import ProvenanceRelationModal from './ProvenanceRelationModal.vue'
const props = defineProps({
provenance: {
type: Array,
default: () => [],
},
})
const api = inject('api')
const settingsStore = useSettingsStore()
const mapEl = ref(null)
const selectedProduct = ref(null)
const selectedRelation = ref(null)
const modalVisible = ref(false)
let map = null
let storeMarker = null
let productMarkers = []
let activeLines = []
const settings = computed(() => settingsStore.settings)
const storePosition = computed(() => {
const current = settings.value
if (!current || current.latitude == null || current.longitude == null) return null
return [Number(current.latitude), Number(current.longitude)]
})
const markers = computed(() => {
const result = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
for (const rel of entry.suppliers || []) {
const municipality = rel.municipality
if (municipality && municipality.latitude != null && municipality.longitude != null) {
result.push({
product: entry.product,
relation: rel,
position: [Number(municipality.latitude), Number(municipality.longitude)],
})
}
}
}
return result
})
const hasMarkers = computed(() => markers.value.length > 0)
const personIcon = L.divIcon({
className: 'provenance-store-pin',
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
iconSize: [46, 46],
iconAnchor: [23, 46],
})
function productIcon (markerData) {
const image = markerData.product.catalogue_images?.[0]
const html = image
? `<img class="provenance-product-img" src="${image}" alt="${markerData.product.name}">`
: '<span class="provenance-product-fallback"><i class="mdi mdi-package-variant"></i></span>'
return L.divIcon({
className: 'provenance-product-pin',
html,
iconSize: [38, 38],
iconAnchor: [19, 38],
})
}
function showLine (from, to) {
const line = L.polyline([from, to], {
color: '#26a69a',
weight: 2,
dashArray: '6 6',
}).addTo(map)
activeLines.push(line)
}
function clearLines () {
activeLines.forEach(line => line.remove())
activeLines = []
}
function addStoreMarker () {
if (!storePosition.value) return
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
storeMarker.bindTooltip('Tienda')
storeMarker.on('mouseover', () => {
markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
})
storeMarker.on('mouseout', clearLines)
}
function addProductMarkers () {
markers.value.forEach(markerData => {
const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
marker.bindTooltip(markerData.product.name)
marker.on('click', () => openDialog(markerData))
marker.on('mouseover', () => {
if (storePosition.value) showLine(markerData.position, storePosition.value)
})
marker.on('mouseout', clearLines)
productMarkers.push(marker)
})
}
function fitBounds () {
const bounds = L.latLngBounds()
markers.value.forEach(markerData => bounds.extend(markerData.position))
if (storePosition.value) bounds.extend(storePosition.value)
if (bounds.isValid()) map.fitBounds(bounds, { padding: [40, 40] })
}
function openDialog (markerData) {
selectedProduct.value = markerData.product
selectedRelation.value = markerData.relation
modalVisible.value = true
}
function initMap () {
if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].position
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map)
addStoreMarker()
addProductMarkers()
fitBounds()
}
function handleResize () {
if (map) map.invalidateSize()
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
try {
await settingsStore.fetchSettings(api)
await nextTick()
if (mapEl.value) initMap()
} catch (error) {
console.error('Error al cargar la configuración de la tienda:', error)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (map) {
map.remove()
map = null
storeMarker = null
productMarkers = []
}
})
</script>
<style scoped>
.map-wrapper {
position: relative;
width: 100%;
height: 420px;
border-radius: 12px;
overflow: hidden;
z-index: 0;
}
.map-wrapper :deep(.provenance-product-pin) {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
}
.map-wrapper :deep(.provenance-product-img) {
width: 38px;
height: 38px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.map-wrapper :deep(.provenance-product-fallback) {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 50%;
background: #26a69a;
color: #ffffff;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
font-size: 24px;
}
.map-wrapper :deep(.provenance-store-pin) {
border: none;
background: transparent;
}
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<v-dialog
max-width="560"
:model-value="visible"
@update:model-value="onUpdateVisible"
>
<v-card v-if="relation">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
<span class="font-weight-bold">{{ productName }}</span>
</v-card-title>
<v-divider />
<v-card-text>
<img
v-if="productImage"
alt="Imagen del producto"
class="provenance-image rounded-lg border mb-3"
:src="productImage"
>
<div
v-for="row in rows"
:key="row.kind"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" :color="row.color" :icon="row.icon" />
<div>
<div class="text-subtitle-2 font-weight-bold">
{{ row.label }}
</div>
<div class="text-body-1">
{{ row.name }}
</div>
<div
v-for="detail in row.details"
:key="detail.label"
class="text-body-2 text-medium-emphasis"
>
{{ detail.label }}: {{ detail.value }}
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="close">Cerrar</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
relation: {
type: Object,
default: null,
},
product: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:visible'])
const ENTITY_INFO = {
supplier: { label: 'Proveedor', icon: 'mdi-truck', color: 'blue' },
organization: { label: 'Organización', icon: 'mdi-domain', color: 'orange' },
municipality: { label: 'Municipio', icon: 'mdi-map-marker', color: 'green' },
department: { label: 'Departamento', icon: 'mdi-map', color: 'indigo' },
country: { label: 'País', icon: 'mdi-earth', color: 'purple' },
}
const productName = computed(() => props.product?.name || 'Producto')
const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
function entityDetails (kind, entity) {
const details = []
if (kind === 'supplier' || kind === 'organization') {
if (entity.description) details.push({ label: 'Descripción', value: entity.description })
if (entity.website) details.push({ label: 'Sitio web', value: entity.website })
if (entity.contact_email) details.push({ label: 'Correo', value: entity.contact_email })
if (entity.contact_phone) details.push({ label: 'Teléfono', value: entity.contact_phone })
}
return details
}
const rows = computed(() => {
const rel = props.relation || {}
return ['supplier', 'organization', 'municipality', 'department', 'country']
.filter(kind => rel[kind])
.map(kind => {
const info = ENTITY_INFO[kind]
return {
kind,
label: info.label,
icon: info.icon,
color: info.color,
name: rel[kind].name,
details: entityDetails(kind, rel[kind]),
}
})
})
function onUpdateVisible (value) {
emit('update:visible', value)
}
function close () {
emit('update:visible', false)
}
</script>
<style scoped>
.provenance-image {
display: block;
max-height: 220px;
max-width: 100%;
object-fit: contain;
}
</style>

View File

@@ -28,12 +28,42 @@
<ProvenanceGraph :provenance="provenance" />
</div>
</v-expand-transition>
<v-divider class="my-4" />
<div
class="d-flex align-center justify-space-between cursor-pointer"
data-test="map-toggle"
role="button"
@click="mapExpanded = !mapExpanded"
>
<h3 class="text-h6 font-weight-bold">
Mapa de origen de los productos
</h3>
<v-btn
data-test="map-toggle-button"
density="comfortable"
:icon="mapExpanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
variant="text"
@click.stop="mapExpanded = !mapExpanded"
/>
</div>
<v-expand-transition>
<div v-if="mapExpanded">
<p class="text-body-2 text-medium-emphasis mb-4">
Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda.
</p>
<ProvenanceMap :provenance="provenance" />
</div>
</v-expand-transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProvenanceGraph from './ProvenanceGraph.vue'
import ProvenanceMap from './ProvenanceMap.vue'
defineProps({
provenance: {
@@ -43,4 +73,5 @@
})
const expanded = ref(false)
const mapExpanded = ref(false)
</script>