#49 feat: panel lateral izquierdo de productos junto al mapa de provenance
This commit is contained in:
@@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido:
|
|||||||
- Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`.
|
- 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 (`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`
|
||||||
- **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 }`
|
||||||
|
|||||||
@@ -1,63 +1,71 @@
|
|||||||
<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-list density="compact">
|
<v-card class="product-panel" data-test="map-product-box" variant="tonal">
|
||||||
<v-list-item
|
<v-card-title class="text-subtitle-1 font-weight-bold py-2">
|
||||||
v-for="row in productRows"
|
Productos ({{ productRows.length }})
|
||||||
:key="row.product.id"
|
</v-card-title>
|
||||||
data-test="map-product-row"
|
<v-divider />
|
||||||
:prepend-avatar="row.image"
|
<v-list density="compact">
|
||||||
role="button"
|
<v-list-item
|
||||||
:subtitle="row.locationLabel"
|
v-for="row in productRows"
|
||||||
:title="row.product.name"
|
:key="row.product.id"
|
||||||
@click="onRowClick(row)"
|
data-test="map-product-row"
|
||||||
>
|
:prepend-avatar="row.image"
|
||||||
<template #append>
|
role="button"
|
||||||
<v-icon v-if="row.position" color="primary" size="small">
|
:subtitle="row.locationLabel"
|
||||||
mdi-map-marker
|
:title="row.product.name"
|
||||||
</v-icon>
|
@click="onRowClick(row)"
|
||||||
<v-icon v-else color="medium-emphasis" size="small">
|
>
|
||||||
mdi-map-marker-off
|
<template #append>
|
||||||
</v-icon>
|
<v-icon v-if="row.position" color="primary" size="small">
|
||||||
</template>
|
mdi-map-marker
|
||||||
</v-list-item>
|
</v-icon>
|
||||||
</v-list>
|
<v-icon v-else color="medium-emphasis" size="small">
|
||||||
</v-card>
|
mdi-map-marker-off
|
||||||
|
</v-icon>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<template v-if="hasMarkers">
|
<div class="map-area">
|
||||||
<div class="map-container">
|
<template v-if="hasMarkers">
|
||||||
<div ref="mapEl" class="map-wrapper" />
|
<div class="map-container">
|
||||||
<v-btn
|
<div ref="mapEl" class="map-wrapper" />
|
||||||
class="map-reset-zoom"
|
<v-btn
|
||||||
data-test="map-reset-zoom"
|
class="map-reset-zoom"
|
||||||
density="comfortable"
|
data-test="map-reset-zoom"
|
||||||
icon="mdi-fit-to-page"
|
density="comfortable"
|
||||||
size="small"
|
icon="mdi-fit-to-page"
|
||||||
variant="flat"
|
size="small"
|
||||||
@click="resetZoom"
|
variant="flat"
|
||||||
/>
|
@click="resetZoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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-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>
|
||||||
<v-alert
|
</div>
|
||||||
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-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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ProvenanceRelationModal
|
<ProvenanceRelationModal
|
||||||
@@ -328,6 +336,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-container {
|
.map-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -348,6 +373,17 @@
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|||||||
Reference in New Issue
Block a user