#49 fix: marcador de producto sin imagen visible en el mapa de provenance
This commit is contained in:
248
src/components/provenance/ProvenanceMap.vue
Normal file
248
src/components/provenance/ProvenanceMap.vue
Normal 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: '© <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>
|
||||
126
src/components/provenance/ProvenanceRelationModal.vue
Normal file
126
src/components/provenance/ProvenanceRelationModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user