Compare commits
8 Commits
a251ae59eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02697f09e1 | |||
| e81dbb8154 | |||
| 4641439454 | |||
| e286424919 | |||
| 6966c4180a | |||
| 798a9bba15 | |||
| f24e7ab2ec | |||
| 6a833a1ea4 |
@@ -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"— 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`
|
- **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 }`
|
||||||
|
|||||||
1105
package-lock.json
generated
1105
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<v-alert
|
<template v-if="productRows.length">
|
||||||
v-if="!hasMarkers"
|
<div class="map-layout">
|
||||||
class="my-4"
|
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
|
||||||
type="info"
|
<v-card-title class="d-flex align-center justify-space-between py-2">
|
||||||
variant="tonal"
|
<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">
|
||||||
|
<v-list-item
|
||||||
|
v-for="row in productRows"
|
||||||
|
:key="row.product.id"
|
||||||
|
data-test="map-product-row"
|
||||||
|
:prepend-avatar="row.image"
|
||||||
|
role="button"
|
||||||
|
:subtitle="row.locationLabel"
|
||||||
|
:title="row.product.name"
|
||||||
|
@click="onRowClick(row)"
|
||||||
>
|
>
|
||||||
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
|
<template #append>
|
||||||
</v-alert>
|
<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>
|
<div class="map-area">
|
||||||
|
<template v-if="hasMarkers">
|
||||||
<div ref="mapEl" class="map-wrapper" />
|
<div ref="mapEl" class="map-wrapper" />
|
||||||
<v-alert
|
<v-alert
|
||||||
v-if="!storePosition"
|
v-if="!storePosition"
|
||||||
@@ -20,9 +53,20 @@
|
|||||||
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
|
La ubicación de la tienda no está configurada, por lo que no se mostrará su recorrido.
|
||||||
</v-alert>
|
</v-alert>
|
||||||
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
<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>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
<v-alert
|
||||||
|
v-else
|
||||||
|
class="my-2"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<ProvenanceRelationModal
|
<ProvenanceRelationModal
|
||||||
:product="selectedProduct"
|
:product="selectedProduct"
|
||||||
@@ -67,36 +111,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 +121,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,8 +129,44 @@
|
|||||||
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 markerGroups = computed(() => {
|
||||||
|
const groups = new Map()
|
||||||
|
for (const markerData of markers.value) {
|
||||||
|
const key = markerData.position.join(',')
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key).push(markerData)
|
||||||
|
}
|
||||||
|
return [...groups.values()].map(items => ({ position: items[0].position, items }))
|
||||||
|
})
|
||||||
|
|
||||||
const personIcon = L.divIcon({
|
const personIcon = L.divIcon({
|
||||||
className: 'provenance-store-pin',
|
className: 'provenance-store-pin',
|
||||||
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
|
html: '<i class="mdi mdi-account-circle" style="font-size:46px;line-height:1;color:#f44336;"></i>',
|
||||||
@@ -137,6 +187,95 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupIcon (group) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'provenance-group-pin',
|
||||||
|
html: `<span class="provenance-group-count">${group.items.length}</span><i class="mdi mdi-package-variant"></i>`,
|
||||||
|
iconSize: [38, 38],
|
||||||
|
iconAnchor: [19, 38],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupPopup (items, onItemClick) {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.className = 'provenance-popup-list'
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const row = document.createElement('div')
|
||||||
|
row.className = 'provenance-popup-item'
|
||||||
|
row.setAttribute('role', 'button')
|
||||||
|
row.setAttribute('data-test', `map-popup-item-${index}`)
|
||||||
|
const image = item.product.catalogue_images?.[0]
|
||||||
|
if (image) {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = image
|
||||||
|
img.className = 'provenance-popup-img'
|
||||||
|
row.appendChild(img)
|
||||||
|
}
|
||||||
|
const name = document.createElement('span')
|
||||||
|
name.className = 'provenance-popup-name'
|
||||||
|
name.textContent = item.product.name
|
||||||
|
row.appendChild(name)
|
||||||
|
row.addEventListener('click', event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
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) {
|
function showLine (from, to) {
|
||||||
const line = L.polyline([from, to], {
|
const line = L.polyline([from, to], {
|
||||||
color: '#26a69a',
|
color: '#26a69a',
|
||||||
@@ -156,48 +295,70 @@
|
|||||||
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
|
storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map)
|
||||||
storeMarker.bindTooltip('Tienda')
|
storeMarker.bindTooltip('Tienda')
|
||||||
storeMarker.on('mouseover', () => {
|
storeMarker.on('mouseover', () => {
|
||||||
productMarkers.forEach(item => showLine(item.marker.getLatLng(), storePosition.value))
|
markers.value.forEach(markerData => showLine(markerData.position, storePosition.value))
|
||||||
})
|
})
|
||||||
storeMarker.on('mouseout', clearLines)
|
storeMarker.on('mouseout', clearLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addProductMarkers () {
|
function addProductMarkers () {
|
||||||
const zoom = map.getZoom()
|
markerGroups.value.forEach(group => {
|
||||||
markers.value.forEach(markerData => {
|
if (group.items.length === 1) {
|
||||||
const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map)
|
const item = group.items[0]
|
||||||
marker.bindTooltip(markerData.product.name)
|
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
|
||||||
marker.on('click', () => openDialog(markerData))
|
marker.bindTooltip(item.product.name)
|
||||||
|
marker.bindPopup(buildDetailPopup(item))
|
||||||
marker.on('mouseover', () => {
|
marker.on('mouseover', () => {
|
||||||
if (storePosition.value) showLine(marker.getLatLng(), storePosition.value)
|
if (storePosition.value) showLine(item.position, storePosition.value)
|
||||||
})
|
})
|
||||||
marker.on('mouseout', clearLines)
|
marker.on('mouseout', clearLines)
|
||||||
productMarkers.push({ marker, data: markerData })
|
productMarkers.push({ marker, items: [item] })
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
|
||||||
function onZoomEnd () {
|
marker.bindTooltip(`${group.items.length} productos en este punto`)
|
||||||
const zoom = map.getZoom()
|
marker.bindPopup(buildGroupPopup(group.items, item => openProductDetail(marker, item)))
|
||||||
productMarkers.forEach(item => {
|
marker.on('popupclose', () => {
|
||||||
item.marker.setLatLng(positionAtZoom(item.data, zoom))
|
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, items: group.items })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 openDialog (markerData) {
|
function onRowClick (row) {
|
||||||
selectedProduct.value = markerData.product
|
if (row.position && map) {
|
||||||
selectedRelation.value = markerData.relation
|
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
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetZoom () {
|
||||||
|
if (map) fitBounds()
|
||||||
|
}
|
||||||
|
|
||||||
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 +366,6 @@
|
|||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
addStoreMarker()
|
addStoreMarker()
|
||||||
addProductMarkers()
|
addProductMarkers()
|
||||||
map.on('zoomend', onZoomEnd)
|
|
||||||
fitBounds()
|
fitBounds()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +396,23 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.map-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-panel {
|
||||||
|
flex: 0 0 300px;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-area {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.map-wrapper {
|
.map-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -245,6 +422,17 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.map-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-panel {
|
||||||
|
flex-basis: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.map-wrapper :deep(.provenance-product-pin) {
|
.map-wrapper :deep(.provenance-product-pin) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -281,6 +469,100 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-group-pin) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #26a69a;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-group-pin .provenance-group-count) {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-group-pin .mdi) {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-popup-item) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-popup-item:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-popup-img) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-wrapper :deep(.provenance-popup-name) {
|
||||||
|
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) {
|
.map-wrapper :deep(.leaflet-control-attribution) {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -116,6 +116,39 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card elevation="2">
|
||||||
|
<v-card-title class="bg-indigo text-white">🏷️ Categorías creadas ({{ result.created_categories?.length || 0 }})</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table
|
||||||
|
:items="formatResults(result.created_categories)"
|
||||||
|
density="compact"
|
||||||
|
:headers="[
|
||||||
|
{ title: 'ID', key: 'id' },
|
||||||
|
{ title: 'Nombre', key: 'name' }
|
||||||
|
]"
|
||||||
|
></v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card elevation="2">
|
||||||
|
<v-card-title class="bg-deep-purple text-white">♻️ Categorías actualizadas ({{ result.updated_categories?.length || 0 }})</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table
|
||||||
|
:items="formatResults(result.updated_categories)"
|
||||||
|
density="compact"
|
||||||
|
:headers="[
|
||||||
|
{ title: 'ID', key: 'id' },
|
||||||
|
{ title: 'Nombre', key: 'name' },
|
||||||
|
{ title: 'Detalle', key: 'detail' }
|
||||||
|
]"
|
||||||
|
></v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" class="text-center mt-4">
|
<v-col cols="12" class="text-center mt-4">
|
||||||
<v-btn color="primary" @click="$router.push('/')">
|
<v-btn color="primary" @click="$router.push('/')">
|
||||||
Volver al inicio
|
Volver al inicio
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const leaflet = vi.hoisted(() => {
|
|||||||
addTo: vi.fn(function () { return this }),
|
addTo: vi.fn(function () { return this }),
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
bindTooltip: vi.fn(function () { return this }),
|
bindTooltip: vi.fn(function () { return this }),
|
||||||
|
bindPopup: vi.fn(function () { return this }),
|
||||||
|
setPopupContent: vi.fn(),
|
||||||
|
openPopup: vi.fn(),
|
||||||
|
closePopup: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
getLatLng: vi.fn(function () { return this.pos }),
|
getLatLng: vi.fn(function () { return this.pos }),
|
||||||
setLatLng: vi.fn(),
|
setLatLng: vi.fn(),
|
||||||
@@ -25,6 +29,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(),
|
||||||
@@ -84,6 +89,17 @@ const provenance = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const sameMuni = [
|
||||||
|
{
|
||||||
|
product: { id: 1, name: 'Panela', catalogue_images: [] },
|
||||||
|
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product: { id: 2, name: 'Arroz', catalogue_images: [] },
|
||||||
|
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
function mountMap (props = {}) {
|
function mountMap (props = {}) {
|
||||||
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
|
const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) }
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
@@ -160,15 +176,23 @@ describe('ProvenanceMap', () => {
|
|||||||
expect(leaflet.polylines[0].remove).toHaveBeenCalled()
|
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()
|
mountMap()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
handlerOf(leaflet.markers[1], 'click')()
|
const marker = leaflet.markers[1]
|
||||||
await nextTick()
|
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')
|
it('no abre el modal web para productos con ubicación', async () => {
|
||||||
expect(document.body.textContent).toContain('Panela regional')
|
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 () => {
|
it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => {
|
||||||
@@ -200,50 +224,155 @@ 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 = [
|
|
||||||
{
|
|
||||||
product: { id: 1, name: 'Panela', catalogue_images: [] },
|
|
||||||
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
product: { id: 2, name: 'Arroz', catalogue_images: [] },
|
|
||||||
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
mountMap({ provenance: sameMuni })
|
mountMap({ provenance: sameMuni })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(','))
|
expect(leaflet.markers).toHaveLength(2)
|
||||||
expect(productPositions).toHaveLength(2)
|
expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588])
|
||||||
expect(new Set(productPositions).size).toBe(2)
|
expect(leaflet.map.on).not.toHaveBeenCalledWith('zoomend', expect.anything())
|
||||||
expect(productPositions).not.toContain('4.631028,-74.461588')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reduce la separación en grados al hacer zoom in para mantener la distancia visual', async () => {
|
it('agrupa los productos del mismo punto en un marcador con contador', async () => {
|
||||||
const sameMuni = [
|
|
||||||
{
|
|
||||||
product: { id: 1, name: 'Panela', catalogue_images: [] },
|
|
||||||
suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
product: { id: 2, name: 'Arroz', catalogue_images: [] },
|
|
||||||
suppliers: [{ supplier: { id: 6, name: 'B' }, municipality: { id: 7, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' } }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
mountMap({ provenance: sameMuni })
|
mountMap({ provenance: sameMuni })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const deg8 = 40 * (360 / (256 * Math.pow(2, 8)))
|
const groupIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].className === 'provenance-group-pin')
|
||||||
expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5)
|
expect(groupIcon).toBeDefined()
|
||||||
|
expect(groupIcon[0].html).toContain('2')
|
||||||
|
expect(groupIcon[0].html).toContain('mdi-package-variant')
|
||||||
|
const groupMarker = leaflet.markers[1]
|
||||||
|
expect(groupMarker.bindPopup).toHaveBeenCalled()
|
||||||
|
expect(groupMarker.bindTooltip.mock.calls[0][0]).toContain('2 productos')
|
||||||
|
})
|
||||||
|
|
||||||
leaflet.map.getZoom.mockReturnValue(9)
|
it('al hacer clic en el marcador agrupado despliega los productos internos', async () => {
|
||||||
const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1]
|
mountMap({ provenance: sameMuni })
|
||||||
zoomHandler()
|
await flushPromises()
|
||||||
|
|
||||||
const deg9 = 40 * (360 / (256 * Math.pow(2, 9)))
|
const popupEl = leaflet.markers[1].bindPopup.mock.calls[0][0]
|
||||||
expect(leaflet.markers[1].setLatLng).toHaveBeenCalled()
|
expect(popupEl.textContent).toContain('Panela')
|
||||||
expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5)
|
expect(popupEl.textContent).toContain('Arroz')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al hacer clic en un producto del popup agrupado muestra su detalle sin cerrar el popup ni propagar el clic al mapa', async () => {
|
||||||
|
mountMap({ provenance: sameMuni })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const groupMarker = leaflet.markers[1]
|
||||||
|
const popupEl = groupMarker.bindPopup.mock.calls[0][0]
|
||||||
|
let bubbled = false
|
||||||
|
popupEl.addEventListener('click', () => { bubbled = true })
|
||||||
|
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
|
||||||
|
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
|
|
||||||
|
expect(bubbled).toBe(false)
|
||||||
|
expect(groupMarker.closePopup).not.toHaveBeenCalled()
|
||||||
|
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 () => {
|
||||||
|
mountMap({ provenance: sameMuni })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[0], 'mouseover')()
|
||||||
|
|
||||||
|
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('al pasar sobre el marcador agrupado dibuja las líneas de sus productos', async () => {
|
||||||
|
mountMap({ provenance: sameMuni })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
handlerOf(leaflet.markers[1], 'mouseover')()
|
||||||
|
|
||||||
|
expect(leaflet.L.polyline).toHaveBeenCalledTimes(2)
|
||||||
|
expect(leaflet.polylines[0].points[0]).toEqual([4.631028, -74.461588])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra un recuadro con todos los productos, incluidos los sin geolocalización', async () => {
|
||||||
|
const withNoGeo = [
|
||||||
|
{
|
||||||
|
product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] },
|
||||||
|
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 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
product: { id: 3, name: 'Miel pura', catalogue_images: [] },
|
||||||
|
suppliers: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const { wrapper } = mountMap({ provenance: withNoGeo })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const rows = wrapper.findAll('[data-test="map-product-row"]')
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="map-product-row"]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
|
||||||
|
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
|
||||||
|
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 () => {
|
||||||
|
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('al hacer clic en el botón de zoom general vuelve a encuadrar todos los marcadores', async () => {
|
||||||
|
const { wrapper } = mountMap()
|
||||||
|
await flushPromises()
|
||||||
|
leaflet.map.fitBounds.mockClear()
|
||||||
|
|
||||||
|
await wrapper.find('[data-test="map-reset-zoom"]').trigger('click')
|
||||||
|
|
||||||
|
expect(leaflet.map.fitBounds).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
82
tests/unit/pages/sincronizar_productos_tryton.spec.js
Normal file
82
tests/unit/pages/sincronizar_productos_tryton.spec.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import ProductsFromTrytonPage from '@/pages/sincronizar_productos_tryton.vue'
|
||||||
|
import vuetify from '@/plugins/vuetify'
|
||||||
|
|
||||||
|
const syncResult = {
|
||||||
|
checked_tryton_products: [{ id: 190, name: 'Producto 1' }],
|
||||||
|
created_products: [{ id: 3, name: 'Producto 1' }],
|
||||||
|
updated_products: [],
|
||||||
|
failed_products: [],
|
||||||
|
untouched_products: [],
|
||||||
|
created_categories: [
|
||||||
|
{ id: 9, name: 'Abarrotes', external_id: '5' },
|
||||||
|
],
|
||||||
|
updated_categories: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'Cereales',
|
||||||
|
external_id: '7',
|
||||||
|
detail: 'Nombre: Granos → Cereales',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApi () {
|
||||||
|
return {
|
||||||
|
getProductsFromTryton: vi.fn().mockResolvedValue(syncResult),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPageAsAdmin (api) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.setUser({ role: 'administrator', username: 'admin' })
|
||||||
|
const wrapper = mount(ProductsFromTrytonPage, {
|
||||||
|
global: { plugins: [pinia, vuetify], provide: { api } },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSync (wrapper) {
|
||||||
|
const button = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find(b => b.text().includes('Iniciar Sincronización'))
|
||||||
|
await button.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('página sincronizar_productos_tryton', () => {
|
||||||
|
it('muestra las categorías creadas al sincronizar productos', async () => {
|
||||||
|
const wrapper = await mountPageAsAdmin(mockApi())
|
||||||
|
await startSync(wrapper)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Categorías creadas (1)')
|
||||||
|
expect(wrapper.text()).toContain('Abarrotes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('muestra las categorías actualizadas con su detalle', async () => {
|
||||||
|
const wrapper = await mountPageAsAdmin(mockApi())
|
||||||
|
await startSync(wrapper)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Categorías actualizadas (1)')
|
||||||
|
expect(wrapper.text()).toContain('Cereales')
|
||||||
|
expect(wrapper.text()).toContain('Nombre: Granos → Cereales')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mantiene el resumen de productos al sincronizar', async () => {
|
||||||
|
const api = mockApi()
|
||||||
|
const wrapper = await mountPageAsAdmin(api)
|
||||||
|
await startSync(wrapper)
|
||||||
|
|
||||||
|
expect(api.getProductsFromTryton).toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('✅ Creados (1)')
|
||||||
|
expect(wrapper.text()).toContain('Producto 1')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user