Compare commits
5 Commits
798a9bba15
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02697f09e1 | |||
| e81dbb8154 | |||
| 4641439454 | |||
| e286424919 | |||
| 6966c4180a |
@@ -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` 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; 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`
|
- **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,7 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<template v-if="productRows.length">
|
<template v-if="productRows.length">
|
||||||
<v-card class="mb-3" data-test="map-product-box" variant="tonal">
|
<div class="map-layout">
|
||||||
|
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
|
||||||
|
<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">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="row in productRows"
|
v-for="row in productRows"
|
||||||
@@ -25,19 +41,9 @@
|
|||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<div class="map-area">
|
||||||
<template v-if="hasMarkers">
|
<template v-if="hasMarkers">
|
||||||
<div class="map-container">
|
|
||||||
<div ref="mapEl" class="map-wrapper" />
|
<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>
|
|
||||||
<v-alert
|
<v-alert
|
||||||
v-if="!storePosition"
|
v-if="!storePosition"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -47,7 +53,7 @@
|
|||||||
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-alert
|
||||||
@@ -58,6 +64,8 @@
|
|||||||
>
|
>
|
||||||
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
|
Las coordenadas geográficas de los municipios de origen aún no están disponibles.
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ProvenanceRelationModal
|
<ProvenanceRelationModal
|
||||||
@@ -188,7 +196,7 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGroupPopup (items) {
|
function buildGroupPopup (items, onItemClick) {
|
||||||
const container = document.createElement('div')
|
const container = document.createElement('div')
|
||||||
container.className = 'provenance-popup-list'
|
container.className = 'provenance-popup-list'
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
@@ -207,12 +215,67 @@
|
|||||||
name.className = 'provenance-popup-name'
|
name.className = 'provenance-popup-name'
|
||||||
name.textContent = item.product.name
|
name.textContent = item.product.name
|
||||||
row.appendChild(name)
|
row.appendChild(name)
|
||||||
row.addEventListener('click', () => openDialog(item))
|
row.addEventListener('click', event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onItemClick(item)
|
||||||
|
})
|
||||||
container.appendChild(row)
|
container.appendChild(row)
|
||||||
})
|
})
|
||||||
return container
|
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',
|
||||||
@@ -243,22 +306,25 @@
|
|||||||
const item = group.items[0]
|
const item = group.items[0]
|
||||||
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
|
const marker = L.marker(item.position, { icon: productIcon(item) }).addTo(map)
|
||||||
marker.bindTooltip(item.product.name)
|
marker.bindTooltip(item.product.name)
|
||||||
marker.on('click', () => openDialog(item))
|
marker.bindPopup(buildDetailPopup(item))
|
||||||
marker.on('mouseover', () => {
|
marker.on('mouseover', () => {
|
||||||
if (storePosition.value) showLine(item.position, storePosition.value)
|
if (storePosition.value) showLine(item.position, storePosition.value)
|
||||||
})
|
})
|
||||||
marker.on('mouseout', clearLines)
|
marker.on('mouseout', clearLines)
|
||||||
productMarkers.push({ marker, data: item })
|
productMarkers.push({ marker, items: [item] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
|
const marker = L.marker(group.position, { icon: groupIcon(group) }).addTo(map)
|
||||||
marker.bindTooltip(`${group.items.length} productos en este punto`)
|
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', () => {
|
marker.on('mouseover', () => {
|
||||||
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
|
if (storePosition.value) group.items.forEach(item => showLine(item.position, storePosition.value))
|
||||||
})
|
})
|
||||||
marker.on('mouseout', clearLines)
|
marker.on('mouseout', clearLines)
|
||||||
productMarkers.push({ marker, data: group.items })
|
productMarkers.push({ marker, items: group.items })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +338,14 @@
|
|||||||
function onRowClick (row) {
|
function onRowClick (row) {
|
||||||
if (row.position && map) {
|
if (row.position && map) {
|
||||||
map.flyTo(row.position, Math.max(map.getZoom(), 12), { duration: 0.6 })
|
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
|
selectedProduct.value = row.product
|
||||||
selectedRelation.value = row.relation
|
selectedRelation.value = row.relation
|
||||||
@@ -282,12 +356,6 @@
|
|||||||
if (map) fitBounds()
|
if (map) fitBounds()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDialog (markerData) {
|
|
||||||
selectedProduct.value = markerData.product
|
|
||||||
selectedRelation.value = markerData.relation
|
|
||||||
modalVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function initMap () {
|
function initMap () {
|
||||||
if (!mapEl.value || map) return
|
if (!mapEl.value || map) return
|
||||||
const center = storePosition.value || markers.value[0].position
|
const center = storePosition.value || markers.value[0].position
|
||||||
@@ -328,8 +396,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.map-container {
|
.map-layout {
|
||||||
position: relative;
|
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 {
|
||||||
@@ -341,11 +422,15 @@
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-reset-zoom {
|
@media (max-width: 900px) {
|
||||||
position: absolute;
|
.map-layout {
|
||||||
top: 12px;
|
flex-direction: column;
|
||||||
right: 12px;
|
}
|
||||||
z-index: 1000;
|
|
||||||
|
.product-panel {
|
||||||
|
flex-basis: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-wrapper :deep(.provenance-product-pin) {
|
.map-wrapper :deep(.provenance-product-pin) {
|
||||||
@@ -432,6 +517,52 @@
|
|||||||
font-weight: 500;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ const leaflet = vi.hoisted(() => {
|
|||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
bindTooltip: vi.fn(function () { return this }),
|
bindTooltip: vi.fn(function () { return this }),
|
||||||
bindPopup: 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(),
|
||||||
@@ -173,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 () => {
|
||||||
@@ -244,17 +255,24 @@ describe('ProvenanceMap', () => {
|
|||||||
expect(popupEl.textContent).toContain('Arroz')
|
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 sin cerrar el popup ni propagar el clic al mapa', async () => {
|
||||||
mountMap({ provenance: sameMuni })
|
mountMap({ provenance: sameMuni })
|
||||||
await flushPromises()
|
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]
|
||||||
|
let bubbled = false
|
||||||
|
popupEl.addEventListener('click', () => { bubbled = true })
|
||||||
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
|
const item = popupEl.querySelector('[data-test="map-popup-item-1"]')
|
||||||
item.dispatchEvent(new Event('click'))
|
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('Arroz')
|
expect(bubbled).toBe(false)
|
||||||
expect(document.body.textContent).toContain('B')
|
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 () => {
|
it('al pasar sobre la tienda dibuja una línea por cada producto agrupado', async () => {
|
||||||
@@ -306,7 +324,7 @@ describe('ProvenanceMap', () => {
|
|||||||
expect(rows[0].text()).toContain('Santa Bárbara')
|
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()
|
const { wrapper } = mountMap()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -315,8 +333,12 @@ describe('ProvenanceMap', () => {
|
|||||||
|
|
||||||
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
|
expect(leaflet.map.flyTo).toHaveBeenCalledTimes(1)
|
||||||
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
|
expect(leaflet.map.flyTo.mock.calls[0][0]).toEqual([6.23, -75.56])
|
||||||
expect(document.body.textContent).toContain('Asociación La Mesa')
|
const marker = leaflet.markers[1]
|
||||||
expect(document.body.textContent).toContain('Panela regional')
|
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 () => {
|
it('al hacer clic en un producto sin geolocalización abre el diálogo con la información disponible sin centrar el mapa', async () => {
|
||||||
|
|||||||
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