#49 feat: recuadro informativo de productos en el mapa de provenance sin desplazar posiciones

This commit is contained in:
2026-08-16 12:19:13 -05:00
parent a251ae59eb
commit 6a833a1ea4
5 changed files with 180 additions and 82 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 }] }]`. - 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, 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; si varios productos coinciden en el mismo municipio se separan en anillo cuyo radio en grados se recalcula en `zoomend` para mantener ~40px de separación visual en cualquier zoom) 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` - **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"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan) 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 marcador 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`
- **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 }`

View File

@@ -1,27 +1,52 @@
<template> <template>
<div> <div>
<v-alert <template v-if="productRows.length">
v-if="!hasMarkers" <v-card class="mb-3" data-test="map-product-box" variant="tonal">
class="my-4" <v-list density="compact">
type="info" <v-list-item
variant="tonal" v-for="row in productRows"
> :key="row.product.id"
Las coordenadas geográficas de los municipios de origen aún no están disponibles. data-test="map-product-row"
</v-alert> :prepend-avatar="row.image"
role="button"
:subtitle="row.locationLabel"
:title="row.product.name"
@click="onRowClick(row)"
>
<template #append>
<v-icon v-if="row.position" color="primary" size="small">
mdi-map-marker
</v-icon>
<v-icon v-else color="medium-emphasis" size="small">
mdi-map-marker-off
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
<template v-else> <template v-if="hasMarkers">
<div ref="mapEl" class="map-wrapper" /> <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>
<v-alert <v-alert
v-if="!storePosition" v-else
class="mt-2" class="my-2"
type="warning" type="info"
variant="tonal" variant="tonal"
> >
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido. Las coordenadas geográficas de los municipios de origen aún no están disponibles.
</v-alert> </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> </template>
<ProvenanceRelationModal <ProvenanceRelationModal
@@ -67,36 +92,6 @@
return [Number(current.latitude), Number(current.longitude)] return [Number(current.latitude), Number(current.longitude)]
}) })
const MIN_PIXEL_GAP = 40
function degreesPerPixel (zoom) {
return 360 / (256 * Math.pow(2, zoom))
}
function groupedPositionKeys () {
const counts = new Map()
for (const markerData of markers.value) {
const key = markerData.basePosition.join(',')
counts.set(key, (counts.get(key) || 0) + 1)
}
return new Set(
[...counts.entries()].filter(entry => entry[1] > 1).map(entry => entry[0])
)
}
function positionAtZoom (markerData, zoom) {
const key = markerData.basePosition.join(',')
if (!groupedPositionKeys().has(key)) return markerData.basePosition
const group = markers.value.filter(m => m.basePosition.join(',') === key)
const index = group.findIndex(m => m === markerData)
const angle = (index / group.length) * Math.PI * 2
const offset = MIN_PIXEL_GAP * degreesPerPixel(zoom)
return [
markerData.basePosition[0] + Math.cos(angle) * offset,
markerData.basePosition[1] + Math.sin(angle) * offset,
]
}
const markers = computed(() => { const markers = computed(() => {
const result = [] const result = []
for (const entry of props.provenance || []) { for (const entry of props.provenance || []) {
@@ -107,7 +102,7 @@
result.push({ result.push({
product: entry.product, product: entry.product,
relation: rel, relation: rel,
basePosition: [Number(municipality.latitude), Number(municipality.longitude)], position: [Number(municipality.latitude), Number(municipality.longitude)],
}) })
} }
} }
@@ -115,6 +110,32 @@
return result return result
}) })
const productRows = computed(() => {
const rows = []
for (const entry of props.provenance || []) {
if (!entry.product) continue
const suppliers = entry.suppliers || []
const geoRelations = suppliers.filter(rel =>
rel.municipality && rel.municipality.latitude != null && rel.municipality.longitude != null
)
const municipalities = [...new Map(
geoRelations.map(rel => [rel.municipality.id, rel.municipality])
).values()]
rows.push({
product: entry.product,
relation: suppliers[0] || null,
image: entry.product.catalogue_images?.[0] || null,
position: geoRelations.length
? [Number(geoRelations[0].municipality.latitude), Number(geoRelations[0].municipality.longitude)]
: null,
locationLabel: municipalities.length
? municipalities.map(m => m.name).join(', ')
: 'Sin geolocalización',
})
}
return rows
})
const hasMarkers = computed(() => markers.value.length > 0) const hasMarkers = computed(() => markers.value.length > 0)
const personIcon = L.divIcon({ const personIcon = L.divIcon({
@@ -162,9 +183,8 @@
} }
function addProductMarkers () { function addProductMarkers () {
const zoom = map.getZoom()
markers.value.forEach(markerData => { markers.value.forEach(markerData => {
const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map) const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map)
marker.bindTooltip(markerData.product.name) marker.bindTooltip(markerData.product.name)
marker.on('click', () => openDialog(markerData)) marker.on('click', () => openDialog(markerData))
marker.on('mouseover', () => { marker.on('mouseover', () => {
@@ -175,20 +195,22 @@
}) })
} }
function onZoomEnd () {
const zoom = map.getZoom()
productMarkers.forEach(item => {
item.marker.setLatLng(positionAtZoom(item.data, zoom))
})
}
function fitBounds () { function fitBounds () {
const bounds = L.latLngBounds() const bounds = L.latLngBounds()
markers.value.forEach(markerData => bounds.extend(markerData.basePosition)) markers.value.forEach(markerData => bounds.extend(markerData.position))
if (storePosition.value) bounds.extend(storePosition.value) if (storePosition.value) bounds.extend(storePosition.value)
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] }) if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] })
} }
function onRowClick (row) {
if (row.position && map) {
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
}
selectedProduct.value = row.product
selectedRelation.value = row.relation
modalVisible.value = true
}
function openDialog (markerData) { function openDialog (markerData) {
selectedProduct.value = markerData.product selectedProduct.value = markerData.product
selectedRelation.value = markerData.relation selectedRelation.value = markerData.relation
@@ -197,7 +219,7 @@
function initMap () { function initMap () {
if (!mapEl.value || map) return if (!mapEl.value || map) return
const center = storePosition.value || markers.value[0].basePosition const center = storePosition.value || markers.value[0].position
map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6) map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6)
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
@@ -205,7 +227,6 @@
}).addTo(map) }).addTo(map)
addStoreMarker() addStoreMarker()
addProductMarkers() addProductMarkers()
map.on('zoomend', onZoomEnd)
fitBounds() fitBounds()
} }

View File

@@ -4,7 +4,7 @@
:model-value="visible" :model-value="visible"
@update:model-value="onUpdateVisible" @update:model-value="onUpdateVisible"
> >
<v-card v-if="relation"> <v-card v-if="relation || product">
<v-card-title class="d-flex align-center"> <v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="teal" icon="mdi-package-variant" /> <v-icon class="mr-2" color="teal" icon="mdi-package-variant" />
<span class="font-weight-bold">{{ productName }}</span> <span class="font-weight-bold">{{ productName }}</span>
@@ -17,6 +17,20 @@
class="provenance-image rounded-lg border mb-3" class="provenance-image rounded-lg border mb-3"
:src="productImage" :src="productImage"
> >
<div
v-if="!hasLocation"
class="d-flex align-start mb-3"
>
<v-icon class="mr-3" color="grey" icon="mdi-map-marker-off" />
<div>
<div class="text-subtitle-2 font-weight-bold">
Geolocalización
</div>
<div class="text-body-1">
{{ locationNote }}
</div>
</div>
</div>
<div <div
v-for="row in rows" v-for="row in rows"
:key="row.kind" :key="row.kind"
@@ -78,6 +92,11 @@
const productName = computed(() => props.product?.name || 'Producto') const productName = computed(() => props.product?.name || 'Producto')
const productImage = computed(() => props.product?.catalogue_images?.[0] || null) const productImage = computed(() => props.product?.catalogue_images?.[0] || null)
const hasLocation = computed(() => !!(props.relation?.municipality || props.relation?.department))
const locationNote = computed(() => {
if (props.relation) return 'Aún sin geolocalización registrada.'
return 'Aún no se ha vinculado un proveedor a este producto.'
})
function entityDetails (kind, entity) { function entityDetails (kind, entity) {
const details = [] const details = []

View File

@@ -25,6 +25,7 @@ const leaflet = vi.hoisted(() => {
const map = { const map = {
setView: vi.fn(function () { return this }), setView: vi.fn(function () { return this }),
fitBounds: vi.fn(), fitBounds: vi.fn(),
flyTo: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
invalidateSize: vi.fn(), invalidateSize: vi.fn(),
on: vi.fn(), on: vi.fn(),
@@ -200,7 +201,7 @@ describe('ProvenanceMap', () => {
expect(wrapper.text()).toContain('coordenadas') expect(wrapper.text()).toContain('coordenadas')
}) })
it('separa los productos del mismo municipio para que no se superpongan', async () => { it('mantiene la posición exacta del municipio aunque varios productos coincidan', async () => {
const sameMuni = [ const sameMuni = [
{ {
product: { id: 1, name: 'Panela', catalogue_images: [] }, product: { id: 1, name: 'Panela', catalogue_images: [] },
@@ -214,36 +215,78 @@ describe('ProvenanceMap', () => {
mountMap({ provenance: sameMuni }) mountMap({ provenance: sameMuni })
await flushPromises() await flushPromises()
const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(',')) const productPositions = leaflet.markers.slice(1).map(marker => marker.pos)
expect(productPositions).toHaveLength(2) expect(productPositions).toHaveLength(2)
expect(new Set(productPositions).size).toBe(2) expect(productPositions[0]).toEqual([4.631028, -74.461588])
expect(productPositions).not.toContain('4.631028,-74.461588') expect(productPositions[1]).toEqual([4.631028, -74.461588])
expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
}) })
it('reduce la separación en grados al hacer zoom in para mantener la distancia visual', async () => { it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
const sameMuni = [ const withNoGeo = [
{ {
product: { id: 1, name: 'Panela', catalogue_images: [] }, product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }], suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
}, },
{ {
product: { id: 2, name: 'Arroz', catalogue_images: [] }, product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }], suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
{
product: { id: 3, name: 'Miel pura', catalogue_images: [] },
suppliers: [],
}, },
] ]
mountMap({ provenance: sameMuni }) const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises() await flushPromises()
const deg8 = 40 * (360 / (256 * Math.pow(2, 8))) const rows = wrapper.findAll('[data-test="map-product-row"]')
expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5) expect(rows).toHaveLength(3)
expect(rows.map(row => row.text().replace(/\s+/g, ' ').trim())).toEqual([
expect.stringContaining('Panela regional'),
expect.stringContaining('Café de montaña'),
expect.stringContaining('Miel pura'),
])
expect(rows[1].text()).toContain('Sin geolocalización')
expect(rows[2].text()).toContain('Sin geolocalización')
expect(rows[0].text()).toContain('Santa Bárbara')
})
leaflet.map.getZoom.mockReturnValue(9) it('al hacer clic en un producto con ubicación centra el mapa y abre el diálogo', async () => {
const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1] const { wrapper } = mountMap()
zoomHandler() await flushPromises()
const deg9 = 40 * (360 / (256 * Math.pow(2, 9))) await wrapper.find('[data-test="map-product-row"]').trigger('click')
expect(leaflet.markers[1].setLatLng).toHaveBeenCalled() await nextTick()
expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5)
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')
})
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
const withNoGeo = [
{
product: { id: 1, name: 'Panela regional', catalogue_images: [] },
suppliers: [{ supplier: { id: 5, name: 'Asociación La Mesa' }, municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 } }],
},
{
product: { id: 2, name: 'Café de montaña', catalogue_images: [] },
suppliers: [{ supplier: { id: 8, name: 'Caficultores del Valle' }, municipality: null }],
},
]
const { wrapper } = mountMap({ provenance: withNoGeo })
await flushPromises()
const noGeoRow = wrapper.findAll('[data-test="map-product-row"]').find(row => row.text().includes('Café de montaña'))
await noGeoRow.trigger('click')
await nextTick()
expect(leaflet.map.flyTo).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Café de montaña')
expect(document.body.textContent).toContain('Caficultores del Valle')
expect(document.body.textContent).toContain('sin geolocalización')
}) })
it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => { it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => {

View File

@@ -67,10 +67,25 @@ describe('ProvenanceRelationModal', () => {
expect(bodyText()).not.toContain('País') expect(bodyText()).not.toContain('País')
}) })
it('no muestra contenido cuando no hay relación', () => { it('muestra el producto y un aviso cuando no hay proveedor vinculado', () => {
mountModal({ relation: null }) mountModal({ relation: null })
expect(bodyText()).toContain('Panela regional')
expect(bodyText()).toContain('no se ha vinculado un proveedor')
})
it('no muestra contenido cuando no hay producto ni relación', () => {
mountModal({ relation: null, product: null })
expect(bodyText()).not.toContain('Proveedor') expect(bodyText()).not.toContain('Proveedor')
expect(bodyText()).not.toContain('Producto')
})
it('indica que aún no tiene geolocalización cuando la relación no tiene municipio', () => {
mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } })
expect(bodyText()).toContain('Asociación La Mesa')
expect(bodyText()).toContain('sin geolocalización')
}) })
it('no muestra el diálogo cuando visible es false', () => { it('no muestra el diálogo cuando visible es false', () => {