#49 fix: marcador de producto sin imagen visible en el mapa de provenance
This commit is contained in:
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
|
|||||||
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
|
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
|
||||||
- **Genérico reutilizable:**
|
- **Genérico reutilizable:**
|
||||||
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
|
- `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto
|
||||||
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto — patrón reutilizable para futuros bloques como el mapa leaflet. `ProvenanceDetailModal.vue`
|
- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:<productId>` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` renderiza un mapa leaflet con un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un producto abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). `ProvenanceDetailModal.vue`
|
||||||
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
|
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue`
|
||||||
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
|
- **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products/<id>/` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products/<id>/`
|
||||||
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
|
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
|
||||||
|
|||||||
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" />
|
<ProvenanceGraph :provenance="provenance" />
|
||||||
</div>
|
</div>
|
||||||
</v-expand-transition>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import ProvenanceGraph from './ProvenanceGraph.vue'
|
import ProvenanceGraph from './ProvenanceGraph.vue'
|
||||||
|
import ProvenanceMap from './ProvenanceMap.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -43,4 +73,5 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
const mapExpanded = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
224
tests/unit/components/provenance/ProvenanceMap.spec.js
Normal file
224
tests/unit/components/provenance/ProvenanceMap.spec.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue'
|
||||||
|
import vuetify from '@/plugins/vuetify'
|
||||||
|
|
||||||
|
const leaflet = vi.hoisted(() => {
|
||||||
|
const markers = []
|
||||||
|
const polylines = []
|
||||||
|
const layers = []
|
||||||
|
function makeLayer (type) {
|
||||||
|
const layer = {
|
||||||
|
type,
|
||||||
|
addTo: vi.fn(function () { return this }),
|
||||||
|
on: vi.fn(),
|
||||||
|
bindTooltip: vi.fn(function () { return this }),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}
|
||||||
|
layers.push(layer)
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
const map = {
|
||||||
|
setView: vi.fn(function () { return this }),
|
||||||
|
fitBounds: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
invalidateSize: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
}
|
||||||
|
const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) }
|
||||||
|
return {
|
||||||
|
markers,
|
||||||
|
polylines,
|
||||||
|
layers,
|
||||||
|
map,
|
||||||
|
bounds,
|
||||||
|
L: {
|
||||||
|
map: vi.fn(() => map),
|
||||||
|
tileLayer: vi.fn(() => makeLayer('tileLayer')),
|
||||||
|
marker: vi.fn((pos, opts) => {
|
||||||
|
const marker = makeLayer('marker')
|
||||||
|
marker.pos = pos
|
||||||
|
marker.opts = opts
|
||||||
|
markers.push(marker)
|
||||||
|
return marker
|
||||||
|
}),
|
||||||
|
divIcon: vi.fn(opts => opts),
|
||||||
|
polyline: vi.fn((points, opts) => {
|
||||||
|
const polyline = makeLayer('polyline')
|
||||||
|
polyline.points = points
|
||||||
|
polyline.opts = opts
|
||||||
|
polylines.push(polyline)
|
||||||
|
return polyline
|
||||||
|
}),
|
||||||
|
latLngBounds: vi.fn(() => bounds),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('leaflet', () => ({ default: leaflet.L }))
|
||||||
|
|
||||||
|
const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' }
|
||||||
|
|
||||||
|
const provenance = [
|
||||||
|
{
|
||||||
|
product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
|
||||||
|
suppliers: [
|
||||||
|
{
|
||||||
|
supplier: { id: 5, name: 'Asociación La Mesa' },
|
||||||
|
organization: { id: 3, name: 'Red de Economía Solidaria' },
|
||||||
|
municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product: { id: 2, name: 'Arroz blanco', catalogue_images: [] },
|
||||||
|
suppliers: [
|
||||||
|
{ supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } },
|
||||||
|
{ supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function mountMap (props = {}) {
|
||||||
|
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const wrapper = mount(ProvenanceMap, {
|
||||||
|
props: { provenance, ...props },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, vuetify],
|
||||||
|
provide: { api },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { api, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlerOf (marker, event) {
|
||||||
|
return marker.on.mock.calls.find(args => args[0] === event)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
leaflet.markers.length = 0
|
||||||
|
leaflet.polylines.length = 0
|
||||||
|
leaflet.layers.length = 0
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProvenanceMap', () => {
|
||||||
|
it('carga la configuración de la tienda al montarse', async () => {
|
||||||
|
const { api } = mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(api.getStoreSettings).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('crea el mapa con la tienda y un marcador por municipio de origen', async () => {
|
||||||
|
mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(leaflet.L.map).toHaveBeenCalledTimes(1)
|
||||||
|
expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1)
|
||||||
|
expect(leaflet.markers).toHaveLength(4)
|
||||||
|
expect(leaflet.map.fitBounds).toHaveBeenCalled()
|
||||||
|
expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => {
|
||||||
|
mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[1], 'mouseover')()
|
||||||
|
|
||||||
|
expect(leaflet.L.polyline).toHaveBeenCalledTimes(1)
|
||||||
|
expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]])
|
||||||
|
expect(leaflet.polylines[0].addTo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => {
|
||||||
|
mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[0], 'mouseover')()
|
||||||
|
|
||||||
|
expect(leaflet.L.polyline).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al salir del marcador se limpian las líneas', async () => {
|
||||||
|
mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[1], 'mouseover')()
|
||||||
|
expect(leaflet.polylines).toHaveLength(1)
|
||||||
|
handlerOf(leaflet.markers[1], 'mouseout')()
|
||||||
|
|
||||||
|
expect(leaflet.polylines[0].remove).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al hacer clic en un producto abre el diálogo con el proveedor', async () => {
|
||||||
|
mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[1], 'click')()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Asociación La Mesa')
|
||||||
|
expect(document.body.textContent).toContain('Panela regional')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
|
||||||
|
const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) }
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
mount(ProvenanceMap, {
|
||||||
|
props: { provenance },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, vuetify],
|
||||||
|
provide: { api },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(leaflet.markers).toHaveLength(3)
|
||||||
|
handlerOf(leaflet.markers[0], 'mouseover')()
|
||||||
|
expect(leaflet.L.polyline).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => {
|
||||||
|
const noCoords = [
|
||||||
|
{ product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] },
|
||||||
|
]
|
||||||
|
const { wrapper } = mountMap({ provenance: noCoords })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(leaflet.L.map).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('coordenadas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {
|
||||||
|
const backendProvenance = [
|
||||||
|
{
|
||||||
|
product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] },
|
||||||
|
suppliers: [
|
||||||
|
{
|
||||||
|
supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' },
|
||||||
|
organization: null,
|
||||||
|
municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' },
|
||||||
|
department: { id: 91, name: 'Cundinamarca' },
|
||||||
|
country: { id: 3, name: 'Colombia', code: 'CO' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
mountMap({ provenance: backendProvenance })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(leaflet.L.map).toHaveBeenCalledTimes(1)
|
||||||
|
expect(leaflet.markers).toHaveLength(2)
|
||||||
|
expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
|
||||||
|
const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback'))
|
||||||
|
expect(productIcon).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue'
|
||||||
|
import vuetify from '@/plugins/vuetify'
|
||||||
|
|
||||||
|
const relation = {
|
||||||
|
supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' },
|
||||||
|
organization: { id: 3, name: 'Red de Economía Solidaria' },
|
||||||
|
municipality: { id: 7, name: 'Santa Bárbara' },
|
||||||
|
department: { id: 2, name: 'Antioquia' },
|
||||||
|
country: { id: 1, name: 'Colombia', code: 'CO' },
|
||||||
|
}
|
||||||
|
const product = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Panela regional',
|
||||||
|
catalogue_images: ['http://localhost/media/panela.jpg'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountModal (props = {}) {
|
||||||
|
return mount(ProvenanceRelationModal, {
|
||||||
|
props: { product, relation, visible: true, ...props },
|
||||||
|
global: { plugins: [vuetify] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyText () {
|
||||||
|
return document.body.textContent
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProvenanceRelationModal', () => {
|
||||||
|
it('muestra el producto y todas las entidades de la relación', () => {
|
||||||
|
mountModal()
|
||||||
|
|
||||||
|
expect(bodyText()).toContain('Panela regional')
|
||||||
|
expect(bodyText()).toContain('Proveedor')
|
||||||
|
expect(bodyText()).toContain('Asociación La Mesa')
|
||||||
|
expect(bodyText()).toContain('Organización')
|
||||||
|
expect(bodyText()).toContain('Red de Economía Solidaria')
|
||||||
|
expect(bodyText()).toContain('Municipio')
|
||||||
|
expect(bodyText()).toContain('Santa Bárbara')
|
||||||
|
expect(bodyText()).toContain('Departamento')
|
||||||
|
expect(bodyText()).toContain('Antioquia')
|
||||||
|
expect(bodyText()).toContain('País')
|
||||||
|
expect(bodyText()).toContain('Colombia')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('incluye los detalles de contacto del proveedor', () => {
|
||||||
|
mountModal()
|
||||||
|
|
||||||
|
expect(bodyText()).toContain('info@mesa.co')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra la imagen del producto cuando existe', () => {
|
||||||
|
mountModal()
|
||||||
|
|
||||||
|
expect(document.querySelector('img').getAttribute('src')).toBe(
|
||||||
|
'http://localhost/media/panela.jpg'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no muestra las entidades ausentes', () => {
|
||||||
|
mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
|
||||||
|
|
||||||
|
expect(bodyText()).not.toContain('Organización')
|
||||||
|
expect(bodyText()).not.toContain('Municipio')
|
||||||
|
expect(bodyText()).not.toContain('Departamento')
|
||||||
|
expect(bodyText()).not.toContain('País')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no muestra contenido cuando no hay relación', () => {
|
||||||
|
mountModal({ relation: null })
|
||||||
|
|
||||||
|
expect(bodyText()).not.toContain('Proveedor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('no muestra el diálogo cuando visible es false', () => {
|
||||||
|
mountModal({ visible: false })
|
||||||
|
|
||||||
|
expect(bodyText()).not.toContain('Asociación La Mesa')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,10 +30,14 @@ const provenance = [
|
|||||||
function mountSection (props = {}) {
|
function mountSection (props = {}) {
|
||||||
return mount(ProvenanceSection, {
|
return mount(ProvenanceSection, {
|
||||||
props: { provenance, ...props },
|
props: { provenance, ...props },
|
||||||
global: { plugins: [vuetify] },
|
global: { plugins: [vuetify], stubs: { ProvenanceMap: true } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapWrapper (wrapper) {
|
||||||
|
return wrapper.findComponent({ name: 'ProvenanceMap' })
|
||||||
|
}
|
||||||
|
|
||||||
describe('ProvenanceSection', () => {
|
describe('ProvenanceSection', () => {
|
||||||
it('no renderiza nada cuando no hay provenance', () => {
|
it('no renderiza nada cuando no hay provenance', () => {
|
||||||
const wrapper = mountSection({ provenance: null })
|
const wrapper = mountSection({ provenance: null })
|
||||||
@@ -84,4 +88,40 @@ describe('ProvenanceSection', () => {
|
|||||||
|
|
||||||
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
|
expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('oculta el mapa por defecto', () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
expect(mapWrapper(wrapper).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="map-toggle"]').trigger('click')
|
||||||
|
expect(mapWrapper(wrapper).exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="map-toggle"]').trigger('click')
|
||||||
|
expect(mapWrapper(wrapper).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('también despliega y repliega el mapa con el botón de chevron', async () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
const button = wrapper.find('[data-test="map-toggle-button"]')
|
||||||
|
expect(button.exists()).toBe(true)
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(mapWrapper(wrapper).exists()).toBe(true)
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(mapWrapper(wrapper).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasa el provenance al mapa al desplegarlo', async () => {
|
||||||
|
const wrapper = mountSection()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="map-toggle"]').trigger('click')
|
||||||
|
|
||||||
|
expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user