#49 feat: detalle de producto en popup de leaflet en el mapa de provenance en lugar de modal web

This commit is contained in:
2026-08-16 14:22:00 -05:00
parent 6966c4180a
commit e286424919
3 changed files with 161 additions and 52 deletions

View File

@@ -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 }] }]`.
- **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
- **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` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) 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; un botón flotante (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `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` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— como panel lateral izquierdo junto al mapa (en columna en pantallas < 900px) y, si hay al menos un municipio con coordenadas, el mapa leaflet al lado: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan; los productos del mismo punto se agrupan en un único marcador con contador que al hacer clic despliega un popup con la lista de productos internos para abrir cada uno) 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; un botón flotante en el título del panel (`data-test="map-reset-zoom"`) re-ejecuta `fitBounds` para volver al zoom general; clic en un marcador individual abre un **popup de leaflet** anclado al ícono con el detalle (imagen + producto + proveedor + organización + territorio); clic en un producto del popup agrupado reemplaza su contenido por el detalle de ese producto. Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el popup de detalle en el marcador; si no, abre `ProvenanceRelationModal.vue` (único caso donde se usa el modal web, pues no hay ícono en el mapa) que muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada." o "Aún no se ha vinculado un proveedor a este producto.". `ProvenanceDetailModal.vue`
- **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productosproveedores, 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>/`
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`

View File

@@ -3,8 +3,19 @@
<template v-if="productRows.length">
<div class="map-layout">
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
<v-card-title class="text-subtitle-1 font-weight-bold py-2">
Productos ({{ productRows.length }})
<v-card-title class="d-flex align-center justify-space-between py-2">
<span class="text-subtitle-1 font-weight-bold">
Productos ({{ productRows.length }})
</span>
<v-btn
v-if="hasMarkers"
data-test="map-reset-zoom"
density="comfortable"
icon="mdi-fit-to-page"
size="small"
variant="flat"
@click="resetZoom"
/>
</v-card-title>
<v-divider />
<v-list density="compact">
@@ -32,18 +43,7 @@
<div class="map-area">
<template v-if="hasMarkers">
<div class="map-container">
<div ref="mapEl" class="map-wrapper" />
<v-btn
class="map-reset-zoom"
data-test="map-reset-zoom"
density="comfortable"
icon="mdi-fit-to-page"
size="small"
variant="flat"
@click="resetZoom"
/>
</div>
<div ref="mapEl" class="map-wrapper" />
<v-alert
v-if="!storePosition"
class="mt-2"
@@ -53,7 +53,7 @@
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.
Pasa el cursor sobre un producto para ver su recorrido hasta la tienda. Haz clic en un marcador para ver el detalle.
</p>
</template>
<v-alert
@@ -196,7 +196,7 @@
})
}
function buildGroupPopup (items) {
function buildGroupPopup (items, onItemClick) {
const container = document.createElement('div')
container.className = 'provenance-popup-list'
items.forEach((item, index) => {
@@ -215,12 +215,64 @@
name.className = 'provenance-popup-name'
name.textContent = item.product.name
row.appendChild(name)
row.addEventListener('click', () => openDialog(item))
row.addEventListener('click', () => onItemClick(item))
container.appendChild(row)
})
return container
}
function appendEntityRow (container, label, icon, color, entity) {
if (!entity) return
const row = document.createElement('div')
row.className = 'provenance-detail-row'
const iconEl = document.createElement('i')
iconEl.className = `mdi ${icon}`
iconEl.style.color = color
row.appendChild(iconEl)
const body = document.createElement('div')
const labelEl = document.createElement('div')
labelEl.className = 'provenance-detail-label'
labelEl.textContent = label
const nameEl = document.createElement('div')
nameEl.className = 'provenance-detail-name'
nameEl.textContent = entity.name
body.appendChild(labelEl)
body.appendChild(nameEl)
row.appendChild(body)
container.appendChild(row)
}
function buildDetailPopup (markerData) {
const container = document.createElement('div')
container.className = 'provenance-detail-popup'
const header = document.createElement('div')
header.className = 'provenance-detail-header'
const image = markerData.product.catalogue_images?.[0]
if (image) {
const img = document.createElement('img')
img.src = image
img.className = 'provenance-detail-img'
header.appendChild(img)
}
const title = document.createElement('div')
title.className = 'provenance-detail-title'
title.textContent = markerData.product.name
header.appendChild(title)
container.appendChild(header)
const rel = markerData.relation || {}
appendEntityRow(container, 'Proveedor', 'mdi-truck', '#1976d2', rel.supplier)
appendEntityRow(container, 'Organización', 'mdi-domain', '#fb8c00', rel.organization)
appendEntityRow(container, 'Municipio', 'mdi-map-marker', '#4caf50', rel.municipality)
appendEntityRow(container, 'Departamento', 'mdi-map', '#3f51b5', rel.department)
appendEntityRow(container, 'País', 'mdi-earth', '#9c27b0', rel.country)
return container
}
function openProductDetail (marker, markerData) {
marker.setPopupContent(buildDetailPopup(markerData))
marker.openPopup()
}
function showLine (from, to) {
const line = L.polyline([from, to], {
color: '#26a69a',
@@ -251,22 +303,25 @@
const item = group.items[0]
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
marker.bindTooltip(item.product.name)
marker.on('click', () => openDialog(item))
marker.bindPopup(buildDetailPopup(item))
marker.on('mouseover', () => {
if (storePosition.value) showLine(item.position, storePosition.value)
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, data: item })
productMarkers.push({ marker, items: [item] })
return
}
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
marker.bindTooltip(`${group.items.length} productos en este punto`)
marker.bindPopup(buildGroupPopup(group.items))
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
marker.on('popupclose', () => {
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
})
marker.on('mouseover', () => {
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
})
marker.on('mouseout', clearLines)
productMarkers.push({ marker, data: group.items })
productMarkers.push({ marker, items: group.items })
})
}
@@ -280,6 +335,14 @@
function onRowClick (row) {
if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
const entry = productMarkers.find(entry =>
entry.items.some(item => item.product.id === row.product.id && item.relation === row.relation)
)
if (entry) {
const item = entry.items.find(i => i.relation === row.relation) || entry.items[0]
openProductDetail(entry.marker, item)
return
}
}
selectedProduct.value = row.product
selectedRelation.value = row.relation
@@ -290,12 +353,6 @@
if (map) fitBounds()
}
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
@@ -353,10 +410,6 @@
min-width: 0;
}
.map-container {
position: relative;
}
.map-wrapper {
position: relative;
width: 100%;
@@ -366,13 +419,6 @@
z-index: 0;
}
.map-reset-zoom {
position: absolute;
top: 12px;
right: 12px;
z-index: 1000;
}
@media (max-width: 900px) {
.map-layout {
flex-direction: column;
@@ -468,6 +514,52 @@
font-weight: 500;
}
.map-wrapper :deep(.provenance-detail-popup) {
min-width: 200px;
max-width: 260px;
}
.map-wrapper :deep(.provenance-detail-header) {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.map-wrapper :deep(.provenance-detail-img) {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.map-wrapper :deep(.provenance-detail-title) {
font-weight: 700;
}
.map-wrapper :deep(.provenance-detail-row) {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 0;
}
.map-wrapper :deep(.provenance-detail-row .mdi) {
font-size: 18px;
margin-top: 2px;
}
.map-wrapper :deep(.provenance-detail-label) {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
opacity: 0.7;
}
.map-wrapper :deep(.provenance-detail-name) {
font-weight: 500;
}
.map-wrapper :deep(.leaflet-control-attribution) {
font-size: 10px;
}

View File

@@ -16,6 +16,8 @@ const leaflet = vi.hoisted(() => {
on: vi.fn(),
bindTooltip: vi.fn(function () { return this }),
bindPopup: vi.fn(function () { return this }),
setPopupContent: vi.fn(),
openPopup: vi.fn(),
remove: vi.fn(),
getLatLng: vi.fn(function () { return this.pos }),
setLatLng: vi.fn(),
@@ -173,15 +175,23 @@ describe('ProvenanceMap', () => {
expect(leaflet.polylines[0].remove).toHaveBeenCalled()
})
it('al hacer clic en un producto abre el diálogo con el proveedor', async () => {
it('muestra el detalle del producto en un popup sobre el marcador', async () => {
mountMap()
await flushPromises()
handlerOf(leaflet.markers[1], 'click')()
await nextTick()
const marker = leaflet.markers[1]
expect(marker.bindPopup).toHaveBeenCalledTimes(1)
const popupEl = marker.bindPopup.mock.calls[0][0]
expect(popupEl.textContent).toContain('Panela regional')
expect(popupEl.textContent).toContain('Asociación La Mesa')
expect(popupEl.textContent).toContain('Santa Bárbara')
})
expect(document.body.textContent).toContain('Asociación La Mesa')
expect(document.body.textContent).toContain('Panela regional')
it('no abre el modal web para productos con ubicación', async () => {
mountMap()
await flushPromises()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
@@ -244,17 +254,20 @@ describe('ProvenanceMap', () => {
expect(popupEl.textContent).toContain('Arroz')
})
it('al hacer clic en un producto del popup agrupado abre el diálogo', async () => {
it('al hacer clic en un producto del popup agrupado muestra su detalle en el marcador', async () => {
mountMap({ provenance: sameMuni })
await flushPromises()
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
const groupMarker = leaflet.markers[1]
const popupEl = groupMarker.bindPopup.mock.calls[0][0]
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
item.dispatchEvent(new Event('click'))
await nextTick()
expect(document.body.textContent).toContain('Arroz')
expect(document.body.textContent).toContain('B')
expect(groupMarker.setPopupContent).toHaveBeenCalledTimes(1)
const detailEl = groupMarker.setPopupContent.mock.calls[0][0]
expect(detailEl.textContent).toContain('Arroz')
expect(detailEl.textContent).toContain('B')
expect(groupMarker.openPopup).toHaveBeenCalled()
})
it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
@@ -306,7 +319,7 @@ describe('ProvenanceMap', () => {
expect(rows[0].text()).toContain('Santa Bárbara')
})
it('al hacer clic en un producto con ubicación centra el mapa y abre el diálogo', async () => {
it('al hacer clic en un producto con ubicación centra el mapa y abre su detalle en el marcador', async () => {
const { wrapper } = mountMap()
await flushPromises()
@@ -315,8 +328,12 @@ describe('ProvenanceMap', () => {
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
expect(document.body.textContent).toContain('Asociación La Mesa')
expect(document.body.textContent).toContain('Panela regional')
const marker = leaflet.markers[1]
expect(marker.setPopupContent).toHaveBeenCalled()
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Panela regional')
expect(marker.setPopupContent.mock.calls[0][0].textContent).toContain('Asociación La Mesa')
expect(marker.openPopup).toHaveBeenCalled()
expect(document.body.textContent).not.toContain('Proveedor')
})
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {