#49 feat: detalle de producto en popup de leaflet en el mapa de provenance en lugar de modal web
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 }] }]`.
|
||||
- **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 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>/`
|
||||
- Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user