From 1200951b51176be6550cfb91b4ba5c33c4729817 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:12:33 -0500 Subject: [PATCH 01/22] #49 feat: servicios API CRUD de organizations, suppliers, countries, departments, municipalities y getProduct --- src/services/api.js | 84 ++++++++++++ src/services/django-api.js | 109 +++++++++++++++ tests/unit/services/django-api.spec.js | 183 +++++++++++++++++++++++++ 3 files changed, 376 insertions(+) diff --git a/src/services/api.js b/src/services/api.js index 20363e0..7423dca 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -15,6 +15,90 @@ class Api { return this.apiImplementation.updateProduct(productId, data); } + getProduct(productId) { + return this.apiImplementation.getProduct(productId); + } + + getOrganizations() { + return this.apiImplementation.getOrganizations(); + } + + createOrganization(data) { + return this.apiImplementation.createOrganization(data); + } + + updateOrganization(id, data) { + return this.apiImplementation.updateOrganization(id, data); + } + + deleteOrganization(id) { + return this.apiImplementation.deleteOrganization(id); + } + + getSuppliers() { + return this.apiImplementation.getSuppliers(); + } + + createSupplier(data) { + return this.apiImplementation.createSupplier(data); + } + + updateSupplier(id, data) { + return this.apiImplementation.updateSupplier(id, data); + } + + deleteSupplier(id) { + return this.apiImplementation.deleteSupplier(id); + } + + getCountries() { + return this.apiImplementation.getCountries(); + } + + createCountry(data) { + return this.apiImplementation.createCountry(data); + } + + updateCountry(id, data) { + return this.apiImplementation.updateCountry(id, data); + } + + deleteCountry(id) { + return this.apiImplementation.deleteCountry(id); + } + + getDepartments() { + return this.apiImplementation.getDepartments(); + } + + createDepartment(data) { + return this.apiImplementation.createDepartment(data); + } + + updateDepartment(id, data) { + return this.apiImplementation.updateDepartment(id, data); + } + + deleteDepartment(id) { + return this.apiImplementation.deleteDepartment(id); + } + + getMunicipalities() { + return this.apiImplementation.getMunicipalities(); + } + + createMunicipality(data) { + return this.apiImplementation.createMunicipality(data); + } + + updateMunicipality(id, data) { + return this.apiImplementation.updateMunicipality(id, data); + } + + deleteMunicipality(id) { + return this.apiImplementation.deleteMunicipality(id); + } + getPaymentMethods() { return this.apiImplementation.getPaymentMethods(); } diff --git a/src/services/django-api.js b/src/services/django-api.js index 11ab232..ec29b46 100644 --- a/src/services/django-api.js +++ b/src/services/django-api.js @@ -18,6 +18,10 @@ class DjangoApi { return http.patch(url, payload).then((r) => r.data); } + deleteRequest(url) { + return http.delete(url).then((r) => r.data); + } + getCustomers() { const url = this.base + "/don_confiao/api/customers/"; return this.getRequest(url); @@ -39,6 +43,111 @@ class DjangoApi { return this.patchRequest(url, data); } + getProduct(productId) { + const url = this.base + `/don_confiao/api/products/${productId}/`; + return this.getRequest(url); + } + + getOrganizations() { + const url = this.base + "/don_confiao/api/organizations/"; + return this.getRequest(url); + } + + createOrganization(data) { + const url = this.base + "/don_confiao/api/organizations/"; + return this.postRequest(url, data); + } + + updateOrganization(id, data) { + const url = this.base + `/don_confiao/api/organizations/${id}/`; + return this.patchRequest(url, data); + } + + deleteOrganization(id) { + const url = this.base + `/don_confiao/api/organizations/${id}/`; + return this.deleteRequest(url); + } + + getSuppliers() { + const url = this.base + "/don_confiao/api/suppliers/"; + return this.getRequest(url); + } + + createSupplier(data) { + const url = this.base + "/don_confiao/api/suppliers/"; + return this.postRequest(url, data); + } + + updateSupplier(id, data) { + const url = this.base + `/don_confiao/api/suppliers/${id}/`; + return this.patchRequest(url, data); + } + + deleteSupplier(id) { + const url = this.base + `/don_confiao/api/suppliers/${id}/`; + return this.deleteRequest(url); + } + + getCountries() { + const url = this.base + "/don_confiao/api/countries/"; + return this.getRequest(url); + } + + createCountry(data) { + const url = this.base + "/don_confiao/api/countries/"; + return this.postRequest(url, data); + } + + updateCountry(id, data) { + const url = this.base + `/don_confiao/api/countries/${id}/`; + return this.patchRequest(url, data); + } + + deleteCountry(id) { + const url = this.base + `/don_confiao/api/countries/${id}/`; + return this.deleteRequest(url); + } + + getDepartments() { + const url = this.base + "/don_confiao/api/departments/"; + return this.getRequest(url); + } + + createDepartment(data) { + const url = this.base + "/don_confiao/api/departments/"; + return this.postRequest(url, data); + } + + updateDepartment(id, data) { + const url = this.base + `/don_confiao/api/departments/${id}/`; + return this.patchRequest(url, data); + } + + deleteDepartment(id) { + const url = this.base + `/don_confiao/api/departments/${id}/`; + return this.deleteRequest(url); + } + + getMunicipalities() { + const url = this.base + "/don_confiao/api/municipalities/"; + return this.getRequest(url); + } + + createMunicipality(data) { + const url = this.base + "/don_confiao/api/municipalities/"; + return this.postRequest(url, data); + } + + updateMunicipality(id, data) { + const url = this.base + `/don_confiao/api/municipalities/${id}/`; + return this.patchRequest(url, data); + } + + deleteMunicipality(id) { + const url = this.base + `/don_confiao/api/municipalities/${id}/`; + return this.deleteRequest(url); + } + getPaymentMethods() { const url = this.base + "/don_confiao/payment_methods/all/select_format"; diff --git a/tests/unit/services/django-api.spec.js b/tests/unit/services/django-api.spec.js index 2124742..fe9cf17 100644 --- a/tests/unit/services/django-api.spec.js +++ b/tests/unit/services/django-api.spec.js @@ -32,3 +32,186 @@ describe('DjangoApi.getPublicOrderSummary', () => { expect(result).toEqual({ code: 'abc123', type: 'sale' }) }) }) + +describe('DjangoApi provenance CRUD', () => { + beforeEach(() => { + vi.stubEnv('VITE_DJANGO_BASE_URL', 'http://backend.test') + http.get.mockReset() + http.post.mockReset() + http.patch.mockReset() + http.delete.mockReset() + http.get.mockResolvedValue({ data: [] }) + http.post.mockResolvedValue({ data: { id: 1 } }) + http.patch.mockResolvedValue({ data: { id: 1 } }) + http.delete.mockResolvedValue({ data: {} }) + }) + + it('getProduct consulta el detalle autenticado del producto', async () => { + const api = new DjangoApi() + + await api.getProduct(3) + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/products/3/' + ) + }) + + it('getOrganizations consulta la lista de organizaciones', async () => { + const api = new DjangoApi() + + await api.getOrganizations() + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/organizations/' + ) + }) + + it('createOrganization hace POST con los datos', async () => { + const api = new DjangoApi() + const payload = { name: 'Red de Economía Solidaria' } + + await api.createOrganization(payload) + + expect(http.post).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/organizations/', + payload + ) + }) + + it('updateOrganization hace PATCH con los datos', async () => { + const api = new DjangoApi() + const payload = { description: 'Nueva descripción' } + + await api.updateOrganization(3, payload) + + expect(http.patch).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/organizations/3/', + payload + ) + }) + + it('deleteOrganization hace DELETE', async () => { + const api = new DjangoApi() + + await api.deleteOrganization(3) + + expect(http.delete).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/organizations/3/' + ) + }) + + it('getSuppliers consulta la lista de proveedores', async () => { + const api = new DjangoApi() + + await api.getSuppliers() + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/suppliers/' + ) + }) + + it('createSupplier hace POST con datos de proveedor', async () => { + const api = new DjangoApi() + const payload = { name: 'Asociación La Mesa', organization: 3, municipality: 7 } + + await api.createSupplier(payload) + + expect(http.post).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/suppliers/', + payload + ) + }) + + it('updateSupplier hace PATCH para desvincular organización con null', async () => { + const api = new DjangoApi() + + await api.updateSupplier(5, { organization: null }) + + expect(http.patch).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/suppliers/5/', + { organization: null } + ) + }) + + it('deleteSupplier hace DELETE', async () => { + const api = new DjangoApi() + + await api.deleteSupplier(5) + + expect(http.delete).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/suppliers/5/' + ) + }) + + it('CRUD de países usa el endpoint countries', async () => { + const api = new DjangoApi() + + await api.getCountries() + await api.createCountry({ name: 'Colombia', code: 'CO' }) + await api.updateCountry(1, { code: 'CO' }) + await api.deleteCountry(1) + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/countries/' + ) + expect(http.post).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/countries/', + { name: 'Colombia', code: 'CO' } + ) + expect(http.patch).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/countries/1/', + { code: 'CO' } + ) + expect(http.delete).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/countries/1/' + ) + }) + + it('CRUD de departamentos usa el endpoint departments', async () => { + const api = new DjangoApi() + + await api.getDepartments() + await api.createDepartment({ name: 'Cundinamarca', country: 1 }) + await api.updateDepartment(2, { name: 'Antioquia' }) + await api.deleteDepartment(2) + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/departments/' + ) + expect(http.post).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/departments/', + { name: 'Cundinamarca', country: 1 } + ) + expect(http.patch).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/departments/2/', + { name: 'Antioquia' } + ) + expect(http.delete).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/departments/2/' + ) + }) + + it('CRUD de municipios usa el endpoint municipalities', async () => { + const api = new DjangoApi() + + await api.getMunicipalities() + await api.createMunicipality({ name: 'La Mesa', department: 2, country: 1 }) + await api.updateMunicipality(7, { name: 'LA MESA' }) + await api.deleteMunicipality(7) + + expect(http.get).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/municipalities/' + ) + expect(http.post).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/municipalities/', + { name: 'La Mesa', department: 2, country: 1 } + ) + expect(http.patch).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/municipalities/7/', + { name: 'LA MESA' } + ) + expect(http.delete).toHaveBeenCalledWith( + 'http://backend.test/don_confiao/api/municipalities/7/' + ) + }) +}) From 21376151f0a247bc65897c7fd8a6637b1a77cae8 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:13:37 -0500 Subject: [PATCH 02/22] =?UTF-8?q?#49=20feat:=20servicio=20gen=C3=A9rico=20?= =?UTF-8?q?de=20layout=20por=20columnas=20y=20adaptador=20cytoscape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/graph/graph-layout.js | 73 +++++++++++++++ .../unit/services/graph/graph-layout.spec.js | 90 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/services/graph/graph-layout.js create mode 100644 tests/unit/services/graph/graph-layout.spec.js diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js new file mode 100644 index 0000000..f1eb3e7 --- /dev/null +++ b/src/services/graph/graph-layout.js @@ -0,0 +1,73 @@ +/** + * Servicio genérico de grafos en capas (columnas). + * + * Provee un layout determinista por columnas (un tipo por columna) y un + * adaptador al formato `elements` de cytoscape.js. No conoce el dominio + * (provenance, organigrama, etc.), solo el modelo de grafo: + * node: { id, kind, label, image?, entity? } + * edge: { from, to } (ids de los nodos) + */ + +const DEFAULTS = { + columnWidth: 220, + rowHeight: 64, + padding: 24, +} + +function groupByColumn (nodes, columnOf) { + const columns = {} + for (const node of nodes) { + const column = columnOf(node) ?? 0 + if (!columns[column]) columns[column] = [] + columns[column].push(node) + } + return columns +} + +export function computeColumnLayout (nodes, edges, options = {}) { + const { columnOf, columnWidth = DEFAULTS.columnWidth, rowHeight = DEFAULTS.rowHeight, padding = DEFAULTS.padding } = options + const columns = groupByColumn(nodes, columnOf) + const columnKeys = Object.keys(columns).map(Number) + const maxColumn = columnKeys.length > 0 ? Math.max(...columnKeys) : 0 + const maxRows = columnKeys.length > 0 ? Math.max(...columnKeys.map(c => columns[c].length)) : 0 + + const positionedNodes = nodes.map(node => { + const column = columnOf(node) ?? 0 + const index = columns[column].indexOf(node) + return { + ...node, + x: padding + column * columnWidth + columnWidth / 2, + y: padding + index * rowHeight + rowHeight / 2, + } + }) + + return { + nodes: positionedNodes, + edges, + width: padding * 2 + (maxColumn + 1) * columnWidth, + height: padding * 2 + maxRows * rowHeight, + } +} + +export function toCytoscapeElements ({ nodes, edges }) { + return [ + ...nodes.map(node => ({ + data: { + id: node.id, + kind: node.kind, + label: node.label, + image: node.image || null, + entity: node.entity || null, + }, + classes: [node.kind], + position: { x: node.x, y: node.y }, + })), + ...edges.map(edge => ({ + data: { + id: `edge:${edge.from}:${edge.to}`, + source: edge.from, + target: edge.to, + }, + })), + ] +} diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js new file mode 100644 index 0000000..eeb8b23 --- /dev/null +++ b/tests/unit/services/graph/graph-layout.spec.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { computeColumnLayout, toCytoscapeElements } from '@/services/graph/graph-layout' + +const nodes = [ + { id: 'product:1', kind: 'product', label: 'Panela' }, + { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, + { id: 'organization:3', kind: 'organization', label: 'Red Solidaria' }, +] + +const edges = [ + { from: 'product:1', to: 'supplier:5' }, + { from: 'supplier:5', to: 'organization:3' }, +] + +function columnOf (node) { + return { product: 0, supplier: 1, organization: 2 }[node.kind] +} + +describe('computeColumnLayout', () => { + it('ubica cada nodo en su columna según la función columnOf', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + const product = result.nodes.find(n => n.id === 'product:1') + const supplier = result.nodes.find(n => n.id === 'supplier:5') + const organization = result.nodes.find(n => n.id === 'organization:3') + + expect(product.x).toBeLessThan(supplier.x) + expect(supplier.x).toBeLessThan(organization.x) + expect(product.x).toBeCloseTo(20 + 0 * 200 + 100) + expect(supplier.x).toBeCloseTo(20 + 1 * 200 + 100) + expect(organization.x).toBeCloseTo(20 + 2 * 200 + 100) + }) + + it('separa verticalmente los nodos que comparten columna y conserva el orden', () => { + const many = [ + { id: 'supplier:1', kind: 'supplier', label: 'A' }, + { id: 'supplier:2', kind: 'supplier', label: 'B' }, + ] + const result = computeColumnLayout(many, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + const a = result.nodes.find(n => n.id === 'supplier:1') + const b = result.nodes.find(n => n.id === 'supplier:2') + + expect(a.y).toBeCloseTo(20 + 80 / 2) + expect(b.y).toBeCloseTo(20 + 80 + 80 / 2) + expect(a.y).toBeLessThan(b.y) + }) + + it('calcula ancho y alto del lienzo según columnas y filas usadas', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + expect(result.width).toBeCloseTo(20 * 2 + 3 * 200) + expect(result.height).toBeCloseTo(20 * 2 + 80) + }) + + it('respeta el orden de aparición de los nodos', () => { + const result = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + + expect(result.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5', 'organization:3']) + }) +}) + +describe('toCytoscapeElements', () => { + it('convierte nodos y aristas al formato elements de cytoscape', () => { + const layout = computeColumnLayout(nodes, edges, { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + const elements = toCytoscapeElements(layout) + + const productNode = elements.find(e => e.data.id === 'product:1') + expect(productNode).toMatchObject({ + position: { x: productNode.position.x, y: productNode.position.y }, + classes: ['product'], + data: { id: 'product:1', kind: 'product', label: 'Panela' }, + }) + expect(typeof productNode.position.x).toBe('number') + expect(typeof productNode.position.y).toBe('number') + + const edge = elements.find(e => e.data.source === 'product:1') + expect(edge).toMatchObject({ data: { source: 'product:1', target: 'supplier:5' } }) + }) + + it('incluye datos extra del nodo en data.entity', () => { + const withEntity = [ + { id: 'product:1', kind: 'product', label: 'Panela', entity: { name: 'Panela', price: 3000 } }, + ] + const layout = computeColumnLayout(withEntity, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + const elements = toCytoscapeElements(layout) + + expect(elements[0].data.entity).toEqual({ name: 'Panela', price: 3000 }) + }) +}) From 8335ad23134a25d3a50d4ab42be952f59ae3314d Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:14:42 -0500 Subject: [PATCH 03/22] #49 feat: builders de grafos de provenance (proveedores-organizaciones y territorios) --- src/components/provenance/provenance-graph.js | 104 ++++++++++++ .../provenance/provenance-graph.spec.js | 159 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 src/components/provenance/provenance-graph.js create mode 100644 tests/unit/components/provenance/provenance-graph.spec.js diff --git a/src/components/provenance/provenance-graph.js b/src/components/provenance/provenance-graph.js new file mode 100644 index 0000000..dc051b3 --- /dev/null +++ b/src/components/provenance/provenance-graph.js @@ -0,0 +1,104 @@ +/** + * Builders de grafos de provenance (específicos del dominio). + * + * Convierten el payload `product_provenance` del resumen de compra/pedido en + * un grafo en capas usando el servicio genérico `services/graph/graph-layout`. + */ + +import { computeColumnLayout } from '@/services/graph/graph-layout' + +const SUPPLIER_ORG_COLUMNS = { product: 0, supplier: 1, organization: 2 } +const TERRITORY_COLUMNS = { product: 0, supplier: 1, municipality: 2, department: 3, country: 4 } + +function makeCollector () { + const nodes = [] + const edges = [] + const seen = new Map() + return { + nodes, + edges, + addNode (kind, entity) { + const id = `${kind}:${entity.id}` + if (!seen.has(id)) { + const node = { + id, + kind, + label: entity.name, + image: entity.catalogue_images?.[0] || null, + entity: { ...entity, kind }, + } + seen.set(id, node) + nodes.push(node) + } + return seen.get(id) + }, + addEdge (from, to) { + edges.push({ from, to }) + }, + } +} + +function layout (collector, columns) { + return computeColumnLayout(collector.nodes, collector.edges, { + columnOf: node => columns[node.kind] ?? 0, + columnWidth: 220, + rowHeight: 72, + padding: 24, + }) +} + +export function buildSupplierOrganizationGraph (provenance) { + const g = makeCollector() + for (const entry of provenance || []) { + if (!entry.product) continue + const product = g.addNode('product', entry.product) + for (const rel of entry.suppliers || []) { + if (!rel.supplier) continue + const supplier = g.addNode('supplier', rel.supplier) + g.addEdge(product.id, supplier.id) + if (rel.organization) { + const organization = g.addNode('organization', rel.organization) + g.addEdge(supplier.id, organization.id) + } + } + } + return layout(g, SUPPLIER_ORG_COLUMNS) +} + +export function buildTerritoryGraph (provenance) { + const g = makeCollector() + for (const entry of provenance || []) { + if (!entry.product) continue + const product = g.addNode('product', entry.product) + for (const rel of entry.suppliers || []) { + if (!rel.supplier) continue + const supplier = g.addNode('supplier', rel.supplier) + g.addEdge(product.id, supplier.id) + if (rel.municipality) { + const municipality = g.addNode('municipality', rel.municipality) + g.addEdge(supplier.id, municipality.id) + if (rel.department) { + const department = g.addNode('department', rel.department) + g.addEdge(municipality.id, department.id) + if (rel.country) { + const country = g.addNode('country', rel.country) + g.addEdge(department.id, country.id) + } + } + } + } + } + return layout(g, TERRITORY_COLUMNS) +} + +export function hasAnySupplier (provenance) { + return (provenance || []).some(entry => (entry.suppliers || []).length > 0) +} + +export function hasAnyTerritory (provenance) { + return (provenance || []).some(entry => + (entry.suppliers || []).some(rel => + rel.municipality || rel.department || rel.country + ) + ) +} diff --git a/tests/unit/components/provenance/provenance-graph.spec.js b/tests/unit/components/provenance/provenance-graph.spec.js new file mode 100644 index 0000000..6b7944b --- /dev/null +++ b/tests/unit/components/provenance/provenance-graph.spec.js @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { + buildSupplierOrganizationGraph, + buildTerritoryGraph, + hasAnySupplier, + hasAnyTerritory, +} from '@/components/provenance/provenance-graph' + +const provenance = [ + { + product: { + id: 1, + name: 'Panela regional por Kg', + catalogue_images: ['http://localhost/media/panela.jpg'], + }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' }, + organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' }, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, + }, + ], + }, +] + +describe('hasAnySupplier', () => { + it('es true cuando algún producto tiene proveedores', () => { + expect(hasAnySupplier(provenance)).toBe(true) + }) + + it('es false cuando todos los productos están sin proveedores', () => { + const empty = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }] + expect(hasAnySupplier(empty)).toBe(false) + }) + + it('es false cuando no hay provenance', () => { + expect(hasAnySupplier([])).toBe(false) + }) +}) + +describe('hasAnyTerritory', () => { + it('es true cuando algún proveedor tiene territorio', () => { + expect(hasAnyTerritory(provenance)).toBe(true) + }) + + it('es false cuando todos los territorios vienen en null', () => { + const noTerritory = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null, municipality: null, department: null, country: null }], + }, + ] + expect(hasAnyTerritory(noTerritory)).toBe(false) + }) + + it('es false cuando no hay suppliers', () => { + const noSuppliers = [{ product: { id: 1, name: 'Panela' }, suppliers: [] }] + expect(hasAnyTerritory(noSuppliers)).toBe(false) + }) +}) + +describe('buildSupplierOrganizationGraph', () => { + it('crea nodos de producto, proveedor y organización con sus aristas', () => { + const graph = buildSupplierOrganizationGraph(provenance) + + const ids = graph.nodes.map(n => n.id) + expect(ids).toContain('product:1') + expect(ids).toContain('supplier:5') + expect(ids).toContain('organization:3') + + expect(graph.edges).toContainEqual({ from: 'product:1', to: 'supplier:5' }) + expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'organization:3' }) + }) + + it('incluye la imagen del producto y el detalle de la entidad', () => { + const graph = buildSupplierOrganizationGraph(provenance) + const product = graph.nodes.find(n => n.id === 'product:1') + + expect(product.image).toBe('http://localhost/media/panela.jpg') + expect(product.label).toBe('Panela regional por Kg') + expect(product.entity.kind).toBe('product') + expect(product.entity.name).toBe('Panela regional por Kg') + }) + + it('omite el nodo de organización cuando la relación es null', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: null }], + }, + ] + const graph = buildSupplierOrganizationGraph(data) + + expect(graph.nodes.map(n => n.id)).not.toContain('organization:') + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + }) + + it('no duplica nodos que se repiten entre productos', () => { + const data = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] }, + { product: { id: 2, name: 'Arroz' }, suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' } }] }, + ] + const graph = buildSupplierOrganizationGraph(data) + + expect(graph.nodes.filter(n => n.id === 'supplier:5')).toHaveLength(1) + expect(graph.nodes.filter(n => n.id === 'organization:3')).toHaveLength(1) + expect(graph.edges).toHaveLength(4) + }) + + it('ubica producto, proveedor y organización en columnas crecientes', () => { + const graph = buildSupplierOrganizationGraph(provenance) + + const product = graph.nodes.find(n => n.id === 'product:1') + const supplier = graph.nodes.find(n => n.id === 'supplier:5') + const organization = graph.nodes.find(n => n.id === 'organization:3') + + expect(product.x).toBeLessThan(supplier.x) + expect(supplier.x).toBeLessThan(organization.x) + }) + + it('devuelve un grafo vacío cuando no hay provenance', () => { + const graph = buildSupplierOrganizationGraph([]) + + expect(graph.nodes).toEqual([]) + expect(graph.edges).toEqual([]) + }) +}) + +describe('buildTerritoryGraph', () => { + it('crea la cadena producto → proveedor → municipio → departamento → país', () => { + const graph = buildTerritoryGraph(provenance) + + const ids = graph.nodes.map(n => n.id) + expect(ids).toContain('product:1') + expect(ids).toContain('supplier:5') + expect(ids).toContain('municipality:7') + expect(ids).toContain('department:2') + expect(ids).toContain('country:1') + + expect(graph.edges).toContainEqual({ from: 'supplier:5', to: 'municipality:7' }) + expect(graph.edges).toContainEqual({ from: 'municipality:7', to: 'department:2' }) + expect(graph.edges).toContainEqual({ from: 'department:2', to: 'country:1' }) + }) + + it('no crea nodos de territorio cuando el proveedor no tiene municipio', () => { + const data = [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }], + }, + ] + const graph = buildTerritoryGraph(data) + + expect(graph.nodes.map(n => n.id)).toEqual(['product:1', 'supplier:5']) + expect(graph.edges).toEqual([{ from: 'product:1', to: 'supplier:5' }]) + }) +}) From efa666dcb303f1b17096d54687af55a857d5dda9 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:16:53 -0500 Subject: [PATCH 04/22] =?UTF-8?q?#49=20feat:=20componente=20gen=C3=A9rico?= =?UTF-8?q?=20CytoscapeChart=20con=20evento=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 9 ++ package.json | 1 + src/components/graph/CytoscapeChart.vue | 75 +++++++++++++++ .../components/graph/CytoscapeChart.spec.js | 94 +++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 src/components/graph/CytoscapeChart.vue create mode 100644 tests/unit/components/graph/CytoscapeChart.spec.js diff --git a/package-lock.json b/package-lock.json index 2312abe..f5a22c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@mdi/font": "7.4.47", "axios": "^1.13.5", "core-js": "^3.37.1", + "cytoscape": "^3.34.1", "leaflet": "^1.9.4", "roboto-fontface": "*", "vee-validate": "^4.14.6", @@ -2752,6 +2753,14 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.1.tgz", + "integrity": "sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", diff --git a/package.json b/package.json index 8818240..a6a6774 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@mdi/font": "7.4.47", "axios": "^1.13.5", "core-js": "^3.37.1", + "cytoscape": "^3.34.1", "leaflet": "^1.9.4", "roboto-fontface": "*", "vee-validate": "^4.14.6", diff --git a/src/components/graph/CytoscapeChart.vue b/src/components/graph/CytoscapeChart.vue new file mode 100644 index 0000000..ced6cc4 --- /dev/null +++ b/src/components/graph/CytoscapeChart.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js new file mode 100644 index 0000000..81cca6e --- /dev/null +++ b/tests/unit/components/graph/CytoscapeChart.spec.js @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' + +const tapHandler = { current: null } +const cy = { + on: vi.fn((event, selector, handler) => { + if (event === 'tap') tapHandler.current = handler + }), + elements: vi.fn(() => ({ remove: vi.fn() })), + add: vi.fn(), + layout: vi.fn(() => ({ run: vi.fn() })), + destroy: vi.fn(), +} + +vi.mock('cytoscape', () => ({ + default: vi.fn(() => cy), +})) + +import cytoscape from 'cytoscape' + +const elements = [ + { data: { id: 'product:1', kind: 'product', label: 'Panela' }, position: { x: 100, y: 100 } }, + { data: { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, position: { x: 320, y: 100 } }, + { data: { id: 'e1', source: 'product:1', target: 'supplier:5' } }, +] + +const styles = [{ selector: 'node', style: { 'background-color': '#eee' } }] + +beforeEach(() => { + vi.clearAllMocks() + tapHandler.current = null +}) + +function mountChart (props = {}) { + return mount(CytoscapeChart, { + props: { elements, styles, ...props }, + }) +} + +describe('CytoscapeChart', () => { + it('crea la instancia de cytoscape con elements, styles y layout', () => { + mountChart() + + expect(cytoscape).toHaveBeenCalledTimes(1) + expect(cytoscape).toHaveBeenCalledWith( + expect.objectContaining({ + elements, + style: styles, + layout: { name: 'preset' }, + }) + ) + }) + + it('permite sobreescribir el layout con el prop layout', () => { + mountChart({ layout: { name: 'breadthfirst' } }) + + expect(cytoscape).toHaveBeenCalledWith( + expect.objectContaining({ layout: { name: 'breadthfirst' } }) + ) + }) + + it('emite select con los datos del nodo al hacer tap', () => { + const wrapper = mountChart() + + tapHandler.current({ target: { data: () => elements[0].data } }) + + expect(wrapper.emitted('select')).toEqual([[elements[0].data]]) + }) + + it('actualiza la instancia cuando cambian los elements', async () => { + const wrapper = mountChart() + const next = [{ data: { id: 'organization:3', kind: 'organization', label: 'Red' } }] + + await wrapper.setProps({ elements: next }) + + expect(cy.elements).toHaveBeenCalled() + expect(cy.add).toHaveBeenCalledWith(next) + expect(cy.layout).toHaveBeenCalled() + }) + + it('destruye la instancia al desmontarse', () => { + const wrapper = mountChart() + + wrapper.unmount() + + expect(cy.destroy).toHaveBeenCalledTimes(1) + }) + + it('no escucha eventos mientras no exista la instancia', () => { + mountChart() + expect(cy.on).toHaveBeenCalledWith('tap', 'node', expect.any(Function)) + }) +}) From 4ecfb6411822884f0c95524f0924ed1e12908c1b Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:27:53 -0500 Subject: [PATCH 05/22] =?UTF-8?q?#49=20feat:=20gr=C3=A1ficos=20de=20proven?= =?UTF-8?q?ance=20(proveedores-organizaciones=20y=20territorios)=20en=20el?= =?UTF-8?q?=20resumen=20p=C3=BAblico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PublicOrderSummary.vue | 5 + .../ProductSupplierOrganizationChart.vue | 95 +++++++++++++++ .../provenance/ProductTerritoryChart.vue | 97 ++++++++++++++++ .../provenance/ProvenanceDetailModal.vue | 108 ++++++++++++++++++ .../provenance/ProvenanceSection.vue | 29 +++++ src/services/graph/graph-layout.js | 25 ++-- .../components/PublicOrderSummary.spec.js | 36 +++++- .../components/graph/CytoscapeChart.spec.js | 3 +- .../ProductSupplierOrganizationChart.spec.js | 87 ++++++++++++++ .../provenance/ProductTerritoryChart.spec.js | 84 ++++++++++++++ .../provenance/ProvenanceDetailModal.spec.js | 88 ++++++++++++++ .../provenance/ProvenanceSection.spec.js | 72 ++++++++++++ .../unit/services/graph/graph-layout.spec.js | 10 ++ 13 files changed, 725 insertions(+), 14 deletions(-) create mode 100644 src/components/provenance/ProductSupplierOrganizationChart.vue create mode 100644 src/components/provenance/ProductTerritoryChart.vue create mode 100644 src/components/provenance/ProvenanceDetailModal.vue create mode 100644 src/components/provenance/ProvenanceSection.vue create mode 100644 tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js create mode 100644 tests/unit/components/provenance/ProductTerritoryChart.spec.js create mode 100644 tests/unit/components/provenance/ProvenanceDetailModal.spec.js create mode 100644 tests/unit/components/provenance/ProvenanceSection.spec.js diff --git a/src/components/PublicOrderSummary.vue b/src/components/PublicOrderSummary.vue index 484b2a8..c45f4e2 100644 --- a/src/components/PublicOrderSummary.vue +++ b/src/components/PublicOrderSummary.vue @@ -40,6 +40,10 @@ + @@ -51,6 +55,7 @@ import OrderLines from '@/components/order/OrderLines.vue' import OrderPayment from '@/components/order/OrderPayment.vue' import OrderTotal from '@/components/order/OrderTotal.vue' + import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' defineProps({ purchase: { diff --git a/src/components/provenance/ProductSupplierOrganizationChart.vue b/src/components/provenance/ProductSupplierOrganizationChart.vue new file mode 100644 index 0000000..5e2d6a4 --- /dev/null +++ b/src/components/provenance/ProductSupplierOrganizationChart.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/provenance/ProductTerritoryChart.vue b/src/components/provenance/ProductTerritoryChart.vue new file mode 100644 index 0000000..d77cb48 --- /dev/null +++ b/src/components/provenance/ProductTerritoryChart.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/provenance/ProvenanceDetailModal.vue b/src/components/provenance/ProvenanceDetailModal.vue new file mode 100644 index 0000000..f013836 --- /dev/null +++ b/src/components/provenance/ProvenanceDetailModal.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue new file mode 100644 index 0000000..efae78d --- /dev/null +++ b/src/components/provenance/ProvenanceSection.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/services/graph/graph-layout.js b/src/services/graph/graph-layout.js index f1eb3e7..ef635ef 100644 --- a/src/services/graph/graph-layout.js +++ b/src/services/graph/graph-layout.js @@ -51,17 +51,20 @@ export function computeColumnLayout (nodes, edges, options = {}) { export function toCytoscapeElements ({ nodes, edges }) { return [ - ...nodes.map(node => ({ - data: { - id: node.id, - kind: node.kind, - label: node.label, - image: node.image || null, - entity: node.entity || null, - }, - classes: [node.kind], - position: { x: node.x, y: node.y }, - })), + ...nodes.map(node => { + const classes = node.image ? [node.kind, 'has-image'] : [node.kind] + return { + data: { + id: node.id, + kind: node.kind, + label: node.label, + image: node.image || null, + entity: node.entity || null, + }, + classes, + position: { x: node.x, y: node.y }, + } + }), ...edges.map(edge => ({ data: { id: `edge:${edge.from}:${edge.to}`, diff --git a/tests/unit/components/PublicOrderSummary.spec.js b/tests/unit/components/PublicOrderSummary.spec.js index d4bb791..45ea149 100644 --- a/tests/unit/components/PublicOrderSummary.spec.js +++ b/tests/unit/components/PublicOrderSummary.spec.js @@ -6,8 +6,15 @@ import OrderCustomer from '@/components/order/OrderCustomer.vue' import OrderLines from '@/components/order/OrderLines.vue' import OrderTotal from '@/components/order/OrderTotal.vue' import OrderPayment from '@/components/order/OrderPayment.vue' +import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' import vuetify from '@/plugins/vuetify' +const ProvenanceSectionStub = { + name: 'ProvenanceSection', + props: ['provenance'], + template: '
', +} + const saleData = { id: 5, code: 'abc123', @@ -33,7 +40,10 @@ const catalogData = { function mountSummary (props) { return mount(PublicOrderSummary, { props, - global: { plugins: [vuetify] }, + global: { + plugins: [vuetify], + stubs: { ProvenanceSection: ProvenanceSectionStub }, + }, }) } @@ -87,4 +97,28 @@ describe('PublicOrderSummary', () => { expect(wrapper.text()).not.toContain('Camilo') expect(wrapper.findComponent(OrderLines).exists()).toBe(false) }) + + it('no renderiza la sección de provenance cuando la respuesta no la incluye', () => { + const wrapper = mountSummary({ purchase: saleData }) + + expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(false) + }) + + it('renderiza la sección de provenance cuando el resumen la incluye', () => { + const withProvenance = { + ...saleData, + product_provenance: [ + { + product: { id: 10, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, organization: { id: 3, name: 'Red' }, municipality: { id: 7, name: 'La Mesa' }, department: { id: 2, name: 'Cundinamarca' }, country: { id: 1, name: 'Colombia', code: 'CO' } }], + }, + ], + } + const wrapper = mountSummary({ purchase: withProvenance }) + + expect(wrapper.findComponent(ProvenanceSection).exists()).toBe(true) + expect(wrapper.findComponent(ProvenanceSection).props('provenance')).toEqual( + withProvenance.product_provenance + ) + }) }) diff --git a/tests/unit/components/graph/CytoscapeChart.spec.js b/tests/unit/components/graph/CytoscapeChart.spec.js index 81cca6e..323d7b8 100644 --- a/tests/unit/components/graph/CytoscapeChart.spec.js +++ b/tests/unit/components/graph/CytoscapeChart.spec.js @@ -1,3 +1,4 @@ +import cytoscape from 'cytoscape' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' @@ -17,8 +18,6 @@ vi.mock('cytoscape', () => ({ default: vi.fn(() => cy), })) -import cytoscape from 'cytoscape' - const elements = [ { data: { id: 'product:1', kind: 'product', label: 'Panela' }, position: { x: 100, y: 100 } }, { data: { id: 'supplier:5', kind: 'supplier', label: 'La Mesa' }, position: { x: 320, y: 100 } }, diff --git a/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js new file mode 100644 index 0000000..ab71d38 --- /dev/null +++ b/tests/unit/components/provenance/ProductSupplierOrganizationChart.spec.js @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' +import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' +import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue' +import vuetify from '@/plugins/vuetify' + +const cy = { + on: vi.fn(), + elements: vi.fn(() => ({ remove: vi.fn() })), + add: vi.fn(), + layout: vi.fn(() => ({ run: vi.fn() })), + destroy: vi.fn(), +} + +vi.mock('cytoscape', () => ({ + default: vi.fn(() => cy), +})) + +const provenance = [ + { + product: { + id: 1, + name: 'Panela regional por Kg', + catalogue_images: ['http://localhost/media/panela.jpg'], + }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa', description: 'Cooperativa' }, + organization: { id: 3, name: 'Red de Economía Solidaria', description: 'Red' }, + municipality: null, + department: null, + country: null, + }, + ], + }, +] + +function mountChart (props = {}) { + return mount(ProductSupplierOrganizationChart, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProductSupplierOrganizationChart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renderiza el gráfico con los elementos de productos, proveedores y organizaciones', () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + expect(chart.exists()).toBe(true) + const elements = chart.props('elements') + const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + expect(nodeIds).toContain('product:1') + expect(nodeIds).toContain('supplier:5') + expect(nodeIds).toContain('organization:3') + }) + + it('no muestra mensaje de próximamente cuando existen relaciones', () => { + const wrapper = mountChart() + + expect(wrapper.text()).not.toContain('próximamente') + }) + + it('muestra mensaje de próximamente cuando ningún producto tiene proveedores', () => { + const wrapper = mountChart({ + provenance: [{ product: { id: 1, name: 'Panela' }, suppliers: [] }], + }) + + expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false) + expect(wrapper.text()).toContain('próximamente') + }) + + it('abre el modal de detalle al seleccionar un nodo', async () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + await chart.vm.$emit('select', { kind: 'supplier', label: 'Asociación Agropecuaria La Mesa', entity: { kind: 'supplier' } }) + + expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) + expect(document.body.textContent).toContain('Asociación Agropecuaria La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProductTerritoryChart.spec.js b/tests/unit/components/provenance/ProductTerritoryChart.spec.js new file mode 100644 index 0000000..528df3b --- /dev/null +++ b/tests/unit/components/provenance/ProductTerritoryChart.spec.js @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CytoscapeChart from '@/components/graph/CytoscapeChart.vue' +import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' +import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue' +import vuetify from '@/plugins/vuetify' + +const cy = { + on: vi.fn(), + elements: vi.fn(() => ({ remove: vi.fn() })), + add: vi.fn(), + layout: vi.fn(() => ({ run: vi.fn() })), + destroy: vi.fn(), +} + +vi.mock('cytoscape', () => ({ + default: vi.fn(() => cy), +})) + +const provenance = [ + { + product: { id: 1, name: 'Panela regional por Kg' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación Agropecuaria La Mesa' }, + organization: null, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, + }, + ], + }, +] + +function mountChart (props = {}) { + return mount(ProductTerritoryChart, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProductTerritoryChart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renderiza el gráfico con municipio, departamento y país', () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + expect(chart.exists()).toBe(true) + const elements = chart.props('elements') + const nodeIds = elements.filter(e => e.data.source === undefined).map(e => e.data.id) + expect(nodeIds).toContain('product:1') + expect(nodeIds).toContain('supplier:5') + expect(nodeIds).toContain('municipality:7') + expect(nodeIds).toContain('department:2') + expect(nodeIds).toContain('country:1') + }) + + it('muestra mensaje de próximamente cuando ningún proveedor tiene territorio', () => { + const wrapper = mountChart({ + provenance: [ + { + product: { id: 1, name: 'Panela' }, + suppliers: [{ supplier: { id: 5, name: 'La Mesa' }, municipality: null, department: null, country: null }], + }, + ], + }) + + expect(wrapper.findComponent(CytoscapeChart).exists()).toBe(false) + expect(wrapper.text()).toContain('próximamente') + }) + + it('abre el modal de detalle al seleccionar un municipio', async () => { + const wrapper = mountChart() + + const chart = wrapper.findComponent(CytoscapeChart) + await chart.vm.$emit('select', { kind: 'municipality', label: 'La Mesa', entity: { kind: 'municipality', name: 'La Mesa' } }) + + expect(wrapper.findComponent(ProvenanceDetailModal).props('visible')).toBe(true) + expect(document.body.textContent).toContain('La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceDetailModal.spec.js b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js new file mode 100644 index 0000000..ecbf0ea --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceDetailModal.spec.js @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ProvenanceDetailModal from '@/components/provenance/ProvenanceDetailModal.vue' +import vuetify from '@/plugins/vuetify' + +const supplier = { + id: 'supplier:5', + kind: 'supplier', + label: 'Asociación Agropecuaria La Mesa', + image: null, + entity: { + id: 5, + name: 'Asociación Agropecuaria La Mesa', + description: 'Cooperativa de campesinos', + website: 'https://agro.example.org', + contact_email: 'contacto@agro.example.org', + contact_phone: '300 123 4567', + kind: 'supplier', + }, +} + +const product = { + id: 'product:1', + kind: 'product', + label: 'Panela regional por Kg', + image: 'http://localhost/media/panela.jpg', + entity: { id: 1, name: 'Panela regional por Kg', kind: 'product' }, +} + +function mountModal (props) { + return mount(ProvenanceDetailModal, { + props, + global: { plugins: [vuetify] }, + }) +} + +function bodyText () { + return document.body.textContent +} + +describe('ProvenanceDetailModal', () => { + it('muestra la información detallada del proveedor seleccionado', () => { + mountModal({ visible: true, selected: supplier }) + + expect(bodyText()).toContain('Proveedor') + expect(bodyText()).toContain('Asociación Agropecuaria La Mesa') + expect(bodyText()).toContain('Cooperativa de campesinos') + expect(bodyText()).toContain('https://agro.example.org') + expect(bodyText()).toContain('contacto@agro.example.org') + expect(bodyText()).toContain('300 123 4567') + }) + + it('muestra la imagen del producto cuando existe', () => { + mountModal({ visible: true, selected: product }) + + expect(document.querySelector('img').getAttribute('src')).toBe( + 'http://localhost/media/panela.jpg' + ) + }) + + it('no muestra contenido cuando no hay entidad seleccionada', () => { + mountModal({ visible: true, selected: null }) + + expect(bodyText()).not.toContain('Proveedor') + expect(bodyText()).not.toContain('Asociación') + }) + + it('no muestra el diálogo cuando visible es false', () => { + mountModal({ visible: false, selected: supplier }) + + expect(bodyText()).not.toContain('Asociación Agropecuaria La Mesa') + }) + + it('omite los campos vacíos', () => { + const minimal = { + id: 'country:1', + kind: 'country', + label: 'Colombia', + image: null, + entity: { id: 1, name: 'Colombia', kind: 'country' }, + } + mountModal({ visible: true, selected: minimal }) + + expect(bodyText()).toContain('Colombia') + expect(bodyText()).not.toContain('Descripción') + expect(bodyText()).not.toContain('Sitio web') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js new file mode 100644 index 0000000..b8c8612 --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import ProductSupplierOrganizationChart from '@/components/provenance/ProductSupplierOrganizationChart.vue' +import ProductTerritoryChart from '@/components/provenance/ProductTerritoryChart.vue' +import ProvenanceSection from '@/components/provenance/ProvenanceSection.vue' +import vuetify from '@/plugins/vuetify' + +const cy = { + on: vi.fn(), + elements: vi.fn(() => ({ remove: vi.fn() })), + add: vi.fn(), + layout: vi.fn(() => ({ run: vi.fn() })), + destroy: vi.fn(), +} + +vi.mock('cytoscape', () => ({ + default: vi.fn(() => cy), +})) + +const provenance = [ + { + product: { id: 1, name: 'Panela regional por Kg' }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación La Mesa' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: { id: 7, name: 'La Mesa' }, + department: { id: 2, name: 'Cundinamarca' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, + }, + ], + }, +] + +function mountSection (props = {}) { + return mount(ProvenanceSection, { + props: { provenance, ...props }, + global: { plugins: [vuetify] }, + }) +} + +describe('ProvenanceSection', () => { + it('no renderiza nada cuando no hay provenance', () => { + const wrapper = mountSection({ provenance: null }) + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false) + }) + + it('no renderiza nada cuando provenance es una lista vacía', () => { + const wrapper = mountSection({ provenance: [] }) + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(false) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(false) + }) + + it('muestra los dos gráficos con sus títulos cuando hay provenance', () => { + const wrapper = mountSection() + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).exists()).toBe(true) + expect(wrapper.findComponent(ProductTerritoryChart).exists()).toBe(true) + expect(wrapper.text()).toContain('Proveedores y organizaciones') + expect(wrapper.text()).toContain('Departamento y municipio') + }) + + it('pasa el provenance a ambos gráficos', () => { + const wrapper = mountSection() + + expect(wrapper.findComponent(ProductSupplierOrganizationChart).props('provenance')).toStrictEqual(provenance) + expect(wrapper.findComponent(ProductTerritoryChart).props('provenance')).toStrictEqual(provenance) + }) +}) diff --git a/tests/unit/services/graph/graph-layout.spec.js b/tests/unit/services/graph/graph-layout.spec.js index eeb8b23..a05afef 100644 --- a/tests/unit/services/graph/graph-layout.spec.js +++ b/tests/unit/services/graph/graph-layout.spec.js @@ -87,4 +87,14 @@ describe('toCytoscapeElements', () => { expect(elements[0].data.entity).toEqual({ name: 'Panela', price: 3000 }) }) + + it('agrega la clase has-image a los nodos con imagen', () => { + const withImage = [ + { id: 'product:1', kind: 'product', label: 'Panela', image: 'http://x/panela.jpg' }, + ] + const layout = computeColumnLayout(withImage, [], { columnOf, columnWidth: 200, rowHeight: 80, padding: 20 }) + const elements = toCytoscapeElements(layout) + + expect(elements[0].classes).toContain('has-image') + }) }) From 760b3031b2731698b6394d5e70b274373ad9b777 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:40:34 -0500 Subject: [PATCH 06/22] =?UTF-8?q?#49=20feat:=20CRUD=20admin=20de=20organiz?= =?UTF-8?q?aciones,=20proveedores=20y=20geograf=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NavBar.vue | 3 + .../provenance/admin/GeographyManagement.vue | 687 ++++++++++++++++++ .../admin/OrganizationsManagement.vue | 299 ++++++++ .../provenance/admin/SuppliersManagement.vue | 338 +++++++++ src/pages/admin/geography.vue | 10 + src/pages/admin/organizations.vue | 10 + src/pages/admin/suppliers.vue | 10 + src/router/index.js | 3 + .../admin/GeographyManagement.spec.js | 114 +++ .../admin/OrganizationsManagement.spec.js | 117 +++ .../admin/SuppliersManagement.spec.js | 125 ++++ .../components/provenance/admin/helpers.js | 23 + 12 files changed, 1739 insertions(+) create mode 100644 src/components/provenance/admin/GeographyManagement.vue create mode 100644 src/components/provenance/admin/OrganizationsManagement.vue create mode 100644 src/components/provenance/admin/SuppliersManagement.vue create mode 100644 src/pages/admin/geography.vue create mode 100644 src/pages/admin/organizations.vue create mode 100644 src/pages/admin/suppliers.vue create mode 100644 tests/unit/components/provenance/admin/GeographyManagement.spec.js create mode 100644 tests/unit/components/provenance/admin/OrganizationsManagement.spec.js create mode 100644 tests/unit/components/provenance/admin/SuppliersManagement.spec.js create mode 100644 tests/unit/components/provenance/admin/helpers.js diff --git a/src/components/NavBar.vue b/src/components/NavBar.vue index 16b566d..7a55b5d 100644 --- a/src/components/NavBar.vue +++ b/src/components/NavBar.vue @@ -111,6 +111,9 @@ { title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'}, { title: 'Datos de la Tienda', route: '/admin/store-settings', icon: 'mdi-map-marker'}, { title: 'Ver Ventas por Catálogo', route: '/admin/catalog-sales', icon: 'mdi-cart-arrow-down'}, + { title: 'Organizaciones', route: '/admin/organizations', icon: 'mdi-domain'}, + { title: 'Proveedores', route: '/admin/suppliers', icon: 'mdi-truck'}, + { title: 'Geografía', route: '/admin/geography', icon: 'mdi-earth'}, { divider: true }, { header: 'Sincronización Tryton' }, { title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'}, diff --git a/src/components/provenance/admin/GeographyManagement.vue b/src/components/provenance/admin/GeographyManagement.vue new file mode 100644 index 0000000..1e5cc0d --- /dev/null +++ b/src/components/provenance/admin/GeographyManagement.vue @@ -0,0 +1,687 @@ + + + diff --git a/src/components/provenance/admin/OrganizationsManagement.vue b/src/components/provenance/admin/OrganizationsManagement.vue new file mode 100644 index 0000000..7e9b085 --- /dev/null +++ b/src/components/provenance/admin/OrganizationsManagement.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/src/components/provenance/admin/SuppliersManagement.vue b/src/components/provenance/admin/SuppliersManagement.vue new file mode 100644 index 0000000..e16a475 --- /dev/null +++ b/src/components/provenance/admin/SuppliersManagement.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/src/pages/admin/geography.vue b/src/pages/admin/geography.vue new file mode 100644 index 0000000..a7582d4 --- /dev/null +++ b/src/pages/admin/geography.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/pages/admin/organizations.vue b/src/pages/admin/organizations.vue new file mode 100644 index 0000000..b6d2741 --- /dev/null +++ b/src/pages/admin/organizations.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/pages/admin/suppliers.vue b/src/pages/admin/suppliers.vue new file mode 100644 index 0000000..c4e7780 --- /dev/null +++ b/src/pages/admin/suppliers.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/router/index.js b/src/router/index.js index 3f712f0..d42774d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -25,6 +25,9 @@ const ADMIN_ROUTES = [ '/admin/catalog-sales', '/admin/catalogue-images', '/admin/store-settings', + '/admin/organizations', + '/admin/suppliers', + '/admin/geography', ] const router = createRouter({ diff --git a/tests/unit/components/provenance/admin/GeographyManagement.spec.js b/tests/unit/components/provenance/admin/GeographyManagement.spec.js new file mode 100644 index 0000000..efb8bb9 --- /dev/null +++ b/tests/unit/components/provenance/admin/GeographyManagement.spec.js @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue' +import vuetify from '@/plugins/vuetify' +import { clickBody, setBodyInput } from './helpers' + +const countries = [ + { id: 1, name: 'Colombia', code: 'CO' }, + { id: 2, name: 'Venezuela', code: 'VE' }, +] + +const departments = [ + { id: 2, name: 'Cundinamarca', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } }, + { id: 3, name: 'Antioquia', country: 1, country_detail: { id: 1, name: 'Colombia', code: 'CO' } }, +] + +const municipalities = [ + { id: 7, name: 'La Mesa', department: 2, country: 1, department_detail: { id: 2, name: 'Cundinamarca' } }, + { id: 8, name: 'San Antonio', department: 3, country: 1, department_detail: { id: 3, name: 'Antioquia' } }, +] + +function mockApi () { + return { + getCountries: vi.fn().mockResolvedValue(countries), + createCountry: vi.fn().mockResolvedValue({}), + updateCountry: vi.fn().mockResolvedValue({}), + deleteCountry: vi.fn().mockResolvedValue({}), + getDepartments: vi.fn().mockResolvedValue(departments), + createDepartment: vi.fn().mockResolvedValue({}), + updateDepartment: vi.fn().mockResolvedValue({}), + deleteDepartment: vi.fn().mockResolvedValue({}), + getMunicipalities: vi.fn().mockResolvedValue(municipalities), + createMunicipality: vi.fn().mockResolvedValue({}), + updateMunicipality: vi.fn().mockResolvedValue({}), + deleteMunicipality: vi.fn().mockResolvedValue({}), + } +} + +function mountComponent (api) { + return mount(GeographyManagement, { + global: { plugins: [vuetify], provide: { api } }, + }) +} + +describe('GeographyManagement', () => { + it('muestra los países por defecto y permite crear uno', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + expect(api.getCountries).toHaveBeenCalled() + expect(wrapper.text()).toContain('Colombia') + + await wrapper.find('[data-testid="geo-country-create"]').trigger('click') + setBodyInput('[data-testid="geo-country-form-name"] input', 'Ecuador') + setBodyInput('[data-testid="geo-country-form-code"] input', 'EC') + clickBody('[data-testid="geo-country-save"]') + await flushPromises() + + expect(api.createCountry).toHaveBeenCalledWith({ name: 'Ecuador', code: 'EC' }) + expect(api.getCountries).toHaveBeenCalledTimes(2) + }) + + it('cambia a la pestaña de departamentos y crea uno', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="geo-tab-departments"]').trigger('click') + await flushPromises() + + expect(api.getDepartments).toHaveBeenCalled() + expect(wrapper.text()).toContain('Cundinamarca') + + await wrapper.find('[data-testid="geo-department-create"]').trigger('click') + setBodyInput('[data-testid="geo-department-form-name"] input', 'Risaralda') + const countrySelect = wrapper.findAllComponents({ name: 'VSelect' }) + .find(select => select.props('items').some(item => item.id === 2)) + await countrySelect.vm.$emit('update:modelValue', 2) + clickBody('[data-testid="geo-department-save"]') + await flushPromises() + + expect(api.createDepartment).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Risaralda', + country: 2, + })) + expect(api.getDepartments).toHaveBeenCalledTimes(2) + }) + + it('cambia a la pestaña de municipios y crea uno con departamento y país', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="geo-tab-municipalities"]').trigger('click') + await flushPromises() + + expect(api.getMunicipalities).toHaveBeenCalled() + expect(wrapper.text()).toContain('San Antonio') + + await wrapper.find('[data-testid="geo-municipality-create"]').trigger('click') + setBodyInput('[data-testid="geo-municipality-form-name"] input', 'Pereira') + await wrapper.findAllComponents({ name: 'VAutocomplete' })[0].vm.$emit('update:modelValue', 3) + clickBody('[data-testid="geo-municipality-save"]') + await flushPromises() + + expect(api.createMunicipality).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Pereira', + department: 3, + country: 1, + })) + expect(api.getMunicipalities).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js b/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js new file mode 100644 index 0000000..eb9307c --- /dev/null +++ b/tests/unit/components/provenance/admin/OrganizationsManagement.spec.js @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.vue' +import vuetify from '@/plugins/vuetify' +import { clickBody, setBodyInput } from './helpers' + +const organizations = [ + { + id: 1, + name: 'Red de Economía Solidaria', + description: 'Red de organizaciones', + website: 'https://red.example.org', + contact_email: 'contacto@red.example.org', + contact_phone: '3001112233', + }, + { + id: 2, + name: 'Cooperativa El Campo', + description: null, + website: null, + contact_email: null, + contact_phone: null, + }, +] + +function mockApi () { + return { + getOrganizations: vi.fn().mockResolvedValue(organizations), + createOrganization: vi.fn().mockResolvedValue({}), + updateOrganization: vi.fn().mockResolvedValue({}), + deleteOrganization: vi.fn().mockResolvedValue({}), + } +} + +function mountComponent (api) { + return mount(OrganizationsManagement, { + global: { plugins: [vuetify], provide: { api } }, + }) +} + +function fillForm () { + setBodyInput('[data-testid="org-form-name"] input', 'Fundación Montaña') + setBodyInput('[data-testid="org-form-description"] textarea', 'Fundación') + setBodyInput('[data-testid="org-form-website"] input', 'https://montana.example.org') + setBodyInput('[data-testid="org-form-email"] input', 'info@montana.example.org') + setBodyInput('[data-testid="org-form-phone"] input', '3109876543') +} + +describe('OrganizationsManagement', () => { + it('carga y muestra las organizaciones', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + expect(api.getOrganizations).toHaveBeenCalled() + expect(wrapper.text()).toContain('Red de Economía Solidaria') + expect(wrapper.text()).toContain('Cooperativa El Campo') + }) + + it('filtra las organizaciones por búsqueda', async () => { + const wrapper = mountComponent(mockApi()) + await flushPromises() + + await wrapper.find('[data-testid="org-search"] input').setValue('Cooperativa') + + expect(wrapper.text()).toContain('Cooperativa El Campo') + expect(wrapper.text()).not.toContain('Red de Economía Solidaria') + }) + + it('crea una organización', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="org-create"]').trigger('click') + fillForm() + clickBody('[data-testid="org-save"]') + await flushPromises() + + expect(api.createOrganization).toHaveBeenCalledWith({ + name: 'Fundación Montaña', + description: 'Fundación', + website: 'https://montana.example.org', + contact_email: 'info@montana.example.org', + contact_phone: '3109876543', + }) + expect(api.getOrganizations).toHaveBeenCalledTimes(2) + }) + + it('edita una organización', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="org-edit-1"]').trigger('click') + setBodyInput('[data-testid="org-form-name"] input', 'Red de Economía Solidaria Colombia') + clickBody('[data-testid="org-save"]') + await flushPromises() + + expect(api.updateOrganization).toHaveBeenCalledWith(1, expect.objectContaining({ + name: 'Red de Economía Solidaria Colombia', + })) + }) + + it('elimina una organización', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="org-delete-2"]').trigger('click') + clickBody('[data-testid="org-confirm-delete"]') + await flushPromises() + + expect(api.deleteOrganization).toHaveBeenCalledWith(2) + expect(api.getOrganizations).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/unit/components/provenance/admin/SuppliersManagement.spec.js b/tests/unit/components/provenance/admin/SuppliersManagement.spec.js new file mode 100644 index 0000000..310bca6 --- /dev/null +++ b/tests/unit/components/provenance/admin/SuppliersManagement.spec.js @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import SuppliersManagement from '@/components/provenance/admin/SuppliersManagement.vue' +import vuetify from '@/plugins/vuetify' +import { clickBody, setBodyInput } from './helpers' + +const organizations = [ + { id: 3, name: 'Red de Economía Solidaria' }, + { id: 4, name: 'Cooperativa El Campo' }, +] + +const municipalities = [ + { id: 7, name: 'La Mesa' }, + { id: 8, name: 'San Antonio' }, +] + +const suppliers = [ + { + id: 5, + name: 'Asociación Agropecuaria La Mesa', + description: 'Cooperativa de campesinos', + organization: 3, + organization_detail: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: 7, + municipality_detail: { id: 7, name: 'La Mesa' }, + contact_email: 'contacto@agro.example.org', + contact_phone: '300 123 4567', + }, + { + id: 6, + name: 'Finca El Paraíso', + description: null, + organization: null, + organization_detail: null, + municipality: null, + municipality_detail: null, + contact_email: null, + contact_phone: null, + }, +] + +function mockApi () { + return { + getSuppliers: vi.fn().mockResolvedValue(suppliers), + getOrganizations: vi.fn().mockResolvedValue(organizations), + getMunicipalities: vi.fn().mockResolvedValue(municipalities), + createSupplier: vi.fn().mockResolvedValue({}), + updateSupplier: vi.fn().mockResolvedValue({}), + deleteSupplier: vi.fn().mockResolvedValue({}), + } +} + +function mountComponent (api) { + return mount(SuppliersManagement, { + global: { plugins: [vuetify], provide: { api } }, + }) +} + +describe('SuppliersManagement', () => { + it('carga y muestra los proveedores con su organización y municipio', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + expect(api.getSuppliers).toHaveBeenCalled() + expect(wrapper.text()).toContain('Asociación Agropecuaria La Mesa') + expect(wrapper.text()).toContain('Red de Economía Solidaria') + expect(wrapper.text()).toContain('La Mesa') + }) + + it('crea un proveedor con organización y municipio', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="supplier-create"]').trigger('click') + + setBodyInput('[data-testid="supplier-form-name"] input', 'Asociación El Cedro') + setBodyInput('[data-testid="supplier-form-description"] textarea', 'Caficultores') + const orgSelect = wrapper.findAllComponents({ name: 'VSelect' }) + .find(select => select.props('items').some(item => item.id === 4)) + await orgSelect.vm.$emit('update:modelValue', 4) + await wrapper.findAllComponents({ name: 'VAutocomplete' })[0].vm.$emit('update:modelValue', 8) + clickBody('[data-testid="supplier-save"]') + await flushPromises() + + expect(api.createSupplier).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Asociación El Cedro', + description: 'Caficultores', + organization: 4, + municipality: 8, + })) + expect(api.getSuppliers).toHaveBeenCalledTimes(2) + }) + + it('edita un proveedor conservando la organización seleccionada', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="supplier-edit-5"]').trigger('click') + setBodyInput('[data-testid="supplier-form-name"] input', 'Asociación Agropecuaria La Mesa Renovada') + clickBody('[data-testid="supplier-save"]') + await flushPromises() + + expect(api.updateSupplier).toHaveBeenCalledWith(5, expect.objectContaining({ + name: 'Asociación Agropecuaria La Mesa Renovada', + organization: 3, + municipality: 7, + })) + }) + + it('elimina un proveedor', async () => { + const api = mockApi() + const wrapper = mountComponent(api) + await flushPromises() + + await wrapper.find('[data-testid="supplier-delete-6"]').trigger('click') + clickBody('[data-testid="supplier-confirm-delete"]') + await flushPromises() + + expect(api.deleteSupplier).toHaveBeenCalledWith(6) + expect(api.getSuppliers).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/unit/components/provenance/admin/helpers.js b/tests/unit/components/provenance/admin/helpers.js new file mode 100644 index 0000000..81a9b70 --- /dev/null +++ b/tests/unit/components/provenance/admin/helpers.js @@ -0,0 +1,23 @@ +// Helpers para interactuar con el contenido de diálogos, que Vuetify +// teleporta a document.body fuera del DOM del wrapper. + +function setNativeValue (el, value) { + const proto = el.tagName === 'TEXTAREA' + ? window.HTMLTextAreaElement.prototype + : window.HTMLInputElement.prototype + const setter = Object.getOwnPropertyDescriptor(proto, 'value').set + setter.call(el, value) + el.dispatchEvent(new Event('input', { bubbles: true })) +} + +export function setBodyInput (selector, value) { + const el = document.body.querySelector(selector) + if (!el) throw new Error(`Input no encontrado en body: ${selector}`) + setNativeValue(el, value) +} + +export function clickBody (selector) { + const el = document.body.querySelector(selector) + if (!el) throw new Error(`Elemento no encontrado en body: ${selector}`) + el.click() +} From 9917d4866fa8b3752ace47d7a8ff5c639558f617 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 17:45:34 -0500 Subject: [PATCH 07/22] =?UTF-8?q?#49=20feat:=20di=C3=A1logo=20para=20vincu?= =?UTF-8?q?lar=20productos=20con=20proveedores=20en=20Gesti=C3=B3n=20de=20?= =?UTF-8?q?Productos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.js | 4 + src/components/ProductsManagement.vue | 29 ++++ .../provenance/admin/SupplierLinkDialog.vue | 126 ++++++++++++++++++ .../admin/SupplierLinkDialog.spec.js | 72 ++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/components/provenance/admin/SupplierLinkDialog.vue create mode 100644 tests/unit/components/provenance/admin/SupplierLinkDialog.spec.js diff --git a/.eslintrc.js b/.eslintrc.js index fdc33b3..8dbbc61 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,6 +31,10 @@ module.exports = { '!src/components/PublicOrderSummary.vue', '!src/components/order/*.vue', '!src/components/order/**/*.vue', + '!src/components/provenance/*.vue', + '!src/components/provenance/**/*.vue', + '!src/components/graph/*.vue', + '!src/services/graph/*.js', '!src/pages/pedido/*.vue', '!src/pages/pedido/**/*.vue', ], diff --git a/src/components/ProductsManagement.vue b/src/components/ProductsManagement.vue index da45e57..10da31c 100644 --- a/src/components/ProductsManagement.vue +++ b/src/components/ProductsManagement.vue @@ -122,6 +122,19 @@ + + + + + diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue index 4f6da26..4f0fcc9 100644 --- a/src/components/provenance/ProvenanceSection.vue +++ b/src/components/provenance/ProvenanceSection.vue @@ -1,16 +1,38 @@ diff --git a/src/components/provenance/provenance-vis.js b/src/components/provenance/provenance-vis.js index e17d99a..c83560d 100644 --- a/src/components/provenance/provenance-vis.js +++ b/src/components/provenance/provenance-vis.js @@ -25,6 +25,8 @@ const COLUMN_X = { country: 560, } +export const KIND_COLORS = COLORS + export function toVisNodes (nodes) { const products = nodes.filter(node => node.kind === 'product') const productY = new Map() diff --git a/tests/unit/components/provenance/ProvenanceGraph.spec.js b/tests/unit/components/provenance/ProvenanceGraph.spec.js index 186f247..b533934 100644 --- a/tests/unit/components/provenance/ProvenanceGraph.spec.js +++ b/tests/unit/components/provenance/ProvenanceGraph.spec.js @@ -44,7 +44,7 @@ function visChart (wrapper) { } function checkbox (wrapper, label) { - return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.props('label') === label) + return wrapper.findAllComponents({ name: 'VCheckbox' }).find(cb => cb.text().trim() === label) } async function setKinds (wrapper, kinds) { @@ -75,10 +75,20 @@ describe('ProvenanceGraph', () => { it('muestra un checkbox por cada nivel con productos y proveedores activos', () => { const wrapper = mountGraph() - const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.props('label')) + const labels = wrapper.findAllComponents({ name: 'VCheckbox' }).map(cb => cb.text().trim()) expect(labels).toEqual(['Productos', 'Proveedores', 'Organizaciones', 'Municipios', 'Departamentos', 'País']) }) + it('dibuja un círculo de color junto a cada checkbox como leyenda', () => { + const wrapper = mountGraph() + + const dots = wrapper.findAll('.legend-dot') + expect(dots).toHaveLength(6) + expect(dots[0].element.style.backgroundColor).toBe('rgb(38, 166, 154)') + expect(dots[1].element.style.backgroundColor).toBe('rgb(66, 165, 245)') + expect(dots[2].element.style.backgroundColor).toBe('rgb(255, 183, 77)') + }) + it('al activar municipios agrega nodos de municipio y la arista proveedor→municipio', async () => { const wrapper = mountGraph() diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js index a8f2ef2..497b07a 100644 --- a/tests/unit/components/provenance/ProvenanceSection.spec.js +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -8,7 +8,7 @@ vi.mock('@/components/graph/VisChart.vue', () => ({ default: { name: 'VisChart', template: '
', - props: ['nodes', 'edges', 'height', 'options'], + props: ['nodes', 'edges', 'height', 'options', 'minVerticalSpacing'], }, })) @@ -47,16 +47,41 @@ describe('ProvenanceSection', () => { expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false) }) - it('muestra el gráfico unificado con su título cuando hay provenance', () => { + it('muestra el título y oculta el gráfico por defecto', () => { const wrapper = mountSection() - expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true) - expect(wrapper.text()).toContain('Origen e historia de los productos') + expect(wrapper.text()).toContain('Origen de los productos') + expect(wrapper.text()).not.toContain('historia') + expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false) }) - it('pasa el provenance al gráfico', () => { + it('muestra el gráfico al hacer clic en el título y lo oculta al volver a hacer clic', async () => { const wrapper = mountSection() + await wrapper.find('[data-test="provenance-toggle"]').trigger('click') + expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true) + + await wrapper.find('[data-test="provenance-toggle"]').trigger('click') + expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false) + }) + + it('también despliega y repliega con el botón de chevron', async () => { + const wrapper = mountSection() + + const button = wrapper.find('[data-test="provenance-toggle-button"]') + expect(button.exists()).toBe(true) + await button.trigger('click') + expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(true) + + await button.trigger('click') + expect(wrapper.findComponent(ProvenanceGraph).exists()).toBe(false) + }) + + it('pasa el provenance al gráfico al desplegarlo', async () => { + const wrapper = mountSection() + + await wrapper.find('[data-test="provenance-toggle"]').trigger('click') + expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance) }) }) From 18026617cea1025c22ea62cf58531dcd6d8a7d11 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 04:10:39 -0500 Subject: [PATCH 15/22] #49 fix: marcador de producto sin imagen visible en el mapa de provenance --- AGENTS.md | 2 +- src/components/provenance/ProvenanceMap.vue | 248 ++++++++++++++++++ .../provenance/ProvenanceRelationModal.vue | 126 +++++++++ .../provenance/ProvenanceSection.vue | 31 +++ .../provenance/ProvenanceMap.spec.js | 224 ++++++++++++++++ .../ProvenanceRelationModal.spec.js | 81 ++++++ .../provenance/ProvenanceSection.spec.js | 42 ++- 7 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 src/components/provenance/ProvenanceMap.vue create mode 100644 src/components/provenance/ProvenanceRelationModal.vue create mode 100644 tests/unit/components/provenance/ProvenanceMap.spec.js create mode 100644 tests/unit/components/provenance/ProvenanceRelationModal.spec.js diff --git a/AGENTS.md b/AGENTS.md index 0bf2639..94a637c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido: - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`. - **Genérico reutilizable:** - `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto -- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (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 — patrón reutilizable para futuros bloques como el mapa leaflet. `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:` (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) 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` - **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//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue new file mode 100644 index 0000000..eaab76b --- /dev/null +++ b/src/components/provenance/ProvenanceMap.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/components/provenance/ProvenanceRelationModal.vue b/src/components/provenance/ProvenanceRelationModal.vue new file mode 100644 index 0000000..afa5fe2 --- /dev/null +++ b/src/components/provenance/ProvenanceRelationModal.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/components/provenance/ProvenanceSection.vue b/src/components/provenance/ProvenanceSection.vue index 4f0fcc9..bbbf351 100644 --- a/src/components/provenance/ProvenanceSection.vue +++ b/src/components/provenance/ProvenanceSection.vue @@ -28,12 +28,42 @@
+ + +
+

+ Mapa de origen de los productos +

+ +
+ + +
+

+ Recorrido de cada producto desde el municipio donde se produce hasta nuestra tienda. +

+ + +
+
diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js new file mode 100644 index 0000000..e87d58c --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceMap.spec.js @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import ProvenanceMap from '@/components/provenance/ProvenanceMap.vue' +import vuetify from '@/plugins/vuetify' + +const leaflet = vi.hoisted(() => { + const markers = [] + const polylines = [] + const layers = [] + function makeLayer (type) { + const layer = { + type, + addTo: vi.fn(function () { return this }), + on: vi.fn(), + bindTooltip: vi.fn(function () { return this }), + remove: vi.fn(), + } + layers.push(layer) + return layer + } + const map = { + setView: vi.fn(function () { return this }), + fitBounds: vi.fn(), + remove: vi.fn(), + invalidateSize: vi.fn(), + on: vi.fn(), + } + const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) } + return { + markers, + polylines, + layers, + map, + bounds, + L: { + map: vi.fn(() => map), + tileLayer: vi.fn(() => makeLayer('tileLayer')), + marker: vi.fn((pos, opts) => { + const marker = makeLayer('marker') + marker.pos = pos + marker.opts = opts + markers.push(marker) + return marker + }), + divIcon: vi.fn(opts => opts), + polyline: vi.fn((points, opts) => { + const polyline = makeLayer('polyline') + polyline.points = points + polyline.opts = opts + polylines.push(polyline) + return polyline + }), + latLngBounds: vi.fn(() => bounds), + }, + } +}) + +vi.mock('leaflet', () => ({ default: leaflet.L })) + +const settings = { latitude: 4.6, longitude: -74.08, address: 'Cra 1 #2-3' } + +const provenance = [ + { + product: { id: 1, name: 'Panela regional', catalogue_images: ['http://localhost/media/panela.jpg'] }, + suppliers: [ + { + supplier: { id: 5, name: 'Asociación La Mesa' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: { id: 7, name: 'Santa Bárbara', latitude: 6.23, longitude: -75.56 }, + }, + ], + }, + { + product: { id: 2, name: 'Arroz blanco', catalogue_images: [] }, + suppliers: [ + { supplier: { id: 6, name: 'Campesinos del Oriente' }, municipality: { id: 8, name: 'La Mesa', latitude: 4.86, longitude: -74.63 } }, + { supplier: { id: 7, name: 'Finca El Paraíso' }, municipality: { id: 9, name: 'San Antonio', latitude: 6.22, longitude: -75.57 } }, + ], + }, +] + +function mountMap (props = {}) { + const api = { getStoreSettings: vi.fn().mockResolvedValue(settings) } + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(ProvenanceMap, { + props: { provenance, ...props }, + global: { + plugins: [pinia, vuetify], + provide: { api }, + }, + }) + return { api, wrapper } +} + +function handlerOf (marker, event) { + return marker.on.mock.calls.find(args => args[0] === event)[1] +} + +beforeEach(() => { + vi.clearAllMocks() + leaflet.markers.length = 0 + leaflet.polylines.length = 0 + leaflet.layers.length = 0 + document.body.innerHTML = '' +}) + +describe('ProvenanceMap', () => { + it('carga la configuración de la tienda al montarse', async () => { + const { api } = mountMap() + await flushPromises() + + expect(api.getStoreSettings).toHaveBeenCalled() + }) + + it('crea el mapa con la tienda y un marcador por municipio de origen', async () => { + mountMap() + await flushPromises() + + expect(leaflet.L.map).toHaveBeenCalledTimes(1) + expect(leaflet.L.tileLayer).toHaveBeenCalledTimes(1) + expect(leaflet.markers).toHaveLength(4) + expect(leaflet.map.fitBounds).toHaveBeenCalled() + expect(leaflet.markers[0].bindTooltip).toHaveBeenCalled() + }) + + it('al pasar sobre un producto dibuja una línea hasta la tienda', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'mouseover')() + + expect(leaflet.L.polyline).toHaveBeenCalledTimes(1) + expect(leaflet.L.polyline.mock.calls[0][0]).toEqual([[6.23, -75.56], [4.6, -74.08]]) + expect(leaflet.polylines[0].addTo).toHaveBeenCalled() + }) + + it('al pasar sobre la tienda dibuja las líneas de todos los productos', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[0], 'mouseover')() + + expect(leaflet.L.polyline).toHaveBeenCalledTimes(3) + }) + + it('al salir del marcador se limpian las líneas', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'mouseover')() + expect(leaflet.polylines).toHaveLength(1) + handlerOf(leaflet.markers[1], 'mouseout')() + + expect(leaflet.polylines[0].remove).toHaveBeenCalled() + }) + + it('al hacer clic en un producto abre el diálogo con el proveedor', async () => { + mountMap() + await flushPromises() + + handlerOf(leaflet.markers[1], 'click')() + await nextTick() + + expect(document.body.textContent).toContain('Asociación La Mesa') + expect(document.body.textContent).toContain('Panela regional') + }) + + it('no dibuja la línea de la tienda cuando la tienda no tiene coordenadas', async () => { + const api = { getStoreSettings: vi.fn().mockResolvedValue({ address: 'Cra 1' }) } + const pinia = createPinia() + setActivePinia(pinia) + mount(ProvenanceMap, { + props: { provenance }, + global: { + plugins: [pinia, vuetify], + provide: { api }, + }, + }) + await flushPromises() + + expect(leaflet.markers).toHaveLength(3) + handlerOf(leaflet.markers[0], 'mouseover')() + expect(leaflet.L.polyline).not.toHaveBeenCalled() + }) + + it('muestra un aviso cuando ningún municipio tiene coordenadas', async () => { + const noCoords = [ + { product: { id: 1, name: 'Panela' }, suppliers: [{ supplier: { id: 5, name: 'A' }, municipality: { id: 7, name: 'M' } }] }, + ] + const { wrapper } = mountMap({ provenance: noCoords }) + await flushPromises() + + expect(leaflet.L.map).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('coordenadas') + }) + + it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => { + const backendProvenance = [ + { + product: { id: 110, name: 'Panela condimentada 150 grs', catalogue_images: [] }, + suppliers: [ + { + supplier: { id: 4, name: 'Asociación Agropecuaria La Mesa' }, + organization: null, + municipality: { id: 2653, name: 'LA MESA', latitude: '4.6310280', longitude: '-74.4615880' }, + department: { id: 91, name: 'Cundinamarca' }, + country: { id: 3, name: 'Colombia', code: 'CO' }, + }, + ], + }, + ] + mountMap({ provenance: backendProvenance }) + await flushPromises() + + expect(leaflet.L.map).toHaveBeenCalledTimes(1) + expect(leaflet.markers).toHaveLength(2) + expect(leaflet.markers[1].pos).toEqual([4.631028, -74.461588]) + const productIcon = leaflet.L.divIcon.mock.calls.find(args => args[0].html.includes('provenance-product-fallback')) + expect(productIcon).toBeDefined() + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceRelationModal.spec.js b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js new file mode 100644 index 0000000..942c3d6 --- /dev/null +++ b/tests/unit/components/provenance/ProvenanceRelationModal.spec.js @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import ProvenanceRelationModal from '@/components/provenance/ProvenanceRelationModal.vue' +import vuetify from '@/plugins/vuetify' + +const relation = { + supplier: { id: 5, name: 'Asociación La Mesa', contact_email: 'info@mesa.co' }, + organization: { id: 3, name: 'Red de Economía Solidaria' }, + municipality: { id: 7, name: 'Santa Bárbara' }, + department: { id: 2, name: 'Antioquia' }, + country: { id: 1, name: 'Colombia', code: 'CO' }, +} +const product = { + id: 1, + name: 'Panela regional', + catalogue_images: ['http://localhost/media/panela.jpg'], +} + +function mountModal (props = {}) { + return mount(ProvenanceRelationModal, { + props: { product, relation, visible: true, ...props }, + global: { plugins: [vuetify] }, + }) +} + +function bodyText () { + return document.body.textContent +} + +describe('ProvenanceRelationModal', () => { + it('muestra el producto y todas las entidades de la relación', () => { + mountModal() + + expect(bodyText()).toContain('Panela regional') + expect(bodyText()).toContain('Proveedor') + expect(bodyText()).toContain('Asociación La Mesa') + expect(bodyText()).toContain('Organización') + expect(bodyText()).toContain('Red de Economía Solidaria') + expect(bodyText()).toContain('Municipio') + expect(bodyText()).toContain('Santa Bárbara') + expect(bodyText()).toContain('Departamento') + expect(bodyText()).toContain('Antioquia') + expect(bodyText()).toContain('País') + expect(bodyText()).toContain('Colombia') + }) + + it('incluye los detalles de contacto del proveedor', () => { + mountModal() + + expect(bodyText()).toContain('info@mesa.co') + }) + + it('muestra la imagen del producto cuando existe', () => { + mountModal() + + expect(document.querySelector('img').getAttribute('src')).toBe( + 'http://localhost/media/panela.jpg' + ) + }) + + it('no muestra las entidades ausentes', () => { + mountModal({ relation: { supplier: { id: 5, name: 'Asociación La Mesa' } } }) + + expect(bodyText()).not.toContain('Organización') + expect(bodyText()).not.toContain('Municipio') + expect(bodyText()).not.toContain('Departamento') + expect(bodyText()).not.toContain('País') + }) + + it('no muestra contenido cuando no hay relación', () => { + mountModal({ relation: null }) + + expect(bodyText()).not.toContain('Proveedor') + }) + + it('no muestra el diálogo cuando visible es false', () => { + mountModal({ visible: false }) + + expect(bodyText()).not.toContain('Asociación La Mesa') + }) +}) diff --git a/tests/unit/components/provenance/ProvenanceSection.spec.js b/tests/unit/components/provenance/ProvenanceSection.spec.js index 497b07a..adc5892 100644 --- a/tests/unit/components/provenance/ProvenanceSection.spec.js +++ b/tests/unit/components/provenance/ProvenanceSection.spec.js @@ -30,10 +30,14 @@ const provenance = [ function mountSection (props = {}) { return mount(ProvenanceSection, { props: { provenance, ...props }, - global: { plugins: [vuetify] }, + global: { plugins: [vuetify], stubs: { ProvenanceMap: true } }, }) } +function mapWrapper (wrapper) { + return wrapper.findComponent({ name: 'ProvenanceMap' }) +} + describe('ProvenanceSection', () => { it('no renderiza nada cuando no hay provenance', () => { const wrapper = mountSection({ provenance: null }) @@ -84,4 +88,40 @@ describe('ProvenanceSection', () => { expect(wrapper.findComponent(ProvenanceGraph).props('provenance')).toStrictEqual(provenance) }) + + it('oculta el mapa por defecto', () => { + const wrapper = mountSection() + + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('muestra el mapa al hacer clic en su título y lo oculta al volver a hacer clic', async () => { + const wrapper = mountSection() + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(true) + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('también despliega y repliega el mapa con el botón de chevron', async () => { + const wrapper = mountSection() + + const button = wrapper.find('[data-test="map-toggle-button"]') + expect(button.exists()).toBe(true) + await button.trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(true) + + await button.trigger('click') + expect(mapWrapper(wrapper).exists()).toBe(false) + }) + + it('pasa el provenance al mapa al desplegarlo', async () => { + const wrapper = mountSection() + + await wrapper.find('[data-test="map-toggle"]').trigger('click') + + expect(mapWrapper(wrapper).props('provenance')).toStrictEqual(provenance) + }) }) From a251ae59eb756686e3899584a26c3335213c96ad Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 04:19:17 -0500 Subject: [PATCH 16/22] #49 feat: separacion de marcadores de provenance escalada por zoom en el mapa --- AGENTS.md | 2 +- src/components/provenance/ProvenanceMap.vue | 55 ++++++++++++++++--- .../provenance/ProvenanceMap.spec.js | 49 +++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 94a637c..7bb2ca8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido: - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`. - **Genérico reutilizable:** - `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto -- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (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) 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:` (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` - **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//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue index eaab76b..829d947 100644 --- a/src/components/provenance/ProvenanceMap.vue +++ b/src/components/provenance/ProvenanceMap.vue @@ -67,6 +67,36 @@ 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 result = [] for (const entry of props.provenance || []) { @@ -77,7 +107,7 @@ result.push({ product: entry.product, relation: rel, - position: [Number(municipality.latitude), Number(municipality.longitude)], + basePosition: [Number(municipality.latitude), Number(municipality.longitude)], }) } } @@ -126,29 +156,37 @@ storeMarker = L.marker(storePosition.value, { icon: personIcon }).addTo(map) storeMarker.bindTooltip('Tienda') storeMarker.on('mouseover', () => { - markers.value.forEach(markerData => showLine(markerData.position, storePosition.value)) + productMarkers.forEach(item => showLine(item.marker.getLatLng(), storePosition.value)) }) storeMarker.on('mouseout', clearLines) } function addProductMarkers () { + const zoom = map.getZoom() markers.value.forEach(markerData => { - const marker = L.marker(markerData.position, { icon: productIcon(markerData) }).addTo(map) + const marker = L.marker(positionAtZoom(markerData, zoom), { icon: productIcon(markerData) }).addTo(map) marker.bindTooltip(markerData.product.name) marker.on('click', () => openDialog(markerData)) marker.on('mouseover', () => { - if (storePosition.value) showLine(markerData.position, storePosition.value) + if (storePosition.value) showLine(marker.getLatLng(), storePosition.value) }) marker.on('mouseout', clearLines) - productMarkers.push(marker) + productMarkers.push({ marker, data: markerData }) + }) + } + + function onZoomEnd () { + const zoom = map.getZoom() + productMarkers.forEach(item => { + item.marker.setLatLng(positionAtZoom(item.data, zoom)) }) } function fitBounds () { const bounds = L.latLngBounds() - markers.value.forEach(markerData => bounds.extend(markerData.position)) + markers.value.forEach(markerData => bounds.extend(markerData.basePosition)) if (storePosition.value) bounds.extend(storePosition.value) - if (bounds.isValid()) map.fitBounds(bounds, { padding: [40, 40] }) + if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60] }) } function openDialog (markerData) { @@ -159,7 +197,7 @@ function initMap () { if (!mapEl.value || map) return - const center = storePosition.value || markers.value[0].position + const center = storePosition.value || markers.value[0].basePosition map = L.map(mapEl.value, { scrollWheelZoom: false }).setView(center, 6) L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, @@ -167,6 +205,7 @@ }).addTo(map) addStoreMarker() addProductMarkers() + map.on('zoomend', onZoomEnd) fitBounds() } diff --git a/tests/unit/components/provenance/ProvenanceMap.spec.js b/tests/unit/components/provenance/ProvenanceMap.spec.js index e87d58c..adecca0 100644 --- a/tests/unit/components/provenance/ProvenanceMap.spec.js +++ b/tests/unit/components/provenance/ProvenanceMap.spec.js @@ -16,6 +16,8 @@ const leaflet = vi.hoisted(() => { on: vi.fn(), bindTooltip: vi.fn(function () { return this }), remove: vi.fn(), + getLatLng: vi.fn(function () { return this.pos }), + setLatLng: vi.fn(), } layers.push(layer) return layer @@ -26,6 +28,7 @@ const leaflet = vi.hoisted(() => { remove: vi.fn(), invalidateSize: vi.fn(), on: vi.fn(), + getZoom: vi.fn(() => 8), } const bounds = { extend: vi.fn(), isValid: vi.fn(() => true) } return { @@ -197,6 +200,52 @@ describe('ProvenanceMap', () => { expect(wrapper.text()).toContain('coordenadas') }) + it('separa los productos del mismo municipio para que no se superpongan', 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 }) + await flushPromises() + + const productPositions = leaflet.markers.slice(1).map(marker => marker.pos.join(',')) + expect(productPositions).toHaveLength(2) + expect(new Set(productPositions).size).toBe(2) + 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 () => { + 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 }) + await flushPromises() + + const deg8 = 40 * (360 / (256 * Math.pow(2, 8))) + expect(leaflet.markers[1].pos[0]).toBeCloseTo(4.631028 + deg8, 5) + + leaflet.map.getZoom.mockReturnValue(9) + const zoomHandler = leaflet.map.on.mock.calls.find(args => args[0] === 'zoomend')[1] + zoomHandler() + + const deg9 = 40 * (360 / (256 * Math.pow(2, 9))) + expect(leaflet.markers[1].setLatLng).toHaveBeenCalled() + expect(leaflet.markers[1].setLatLng.mock.calls[0][0][0]).toBeCloseTo(4.631028 + deg9, 5) + }) + it('lee las coordenadas string del backend y da fondo visible al producto sin imagen', async () => { const backendProvenance = [ { From 6a833a1ea4a2586c4af08842a4f17242f99f4f66 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 12:19:13 -0500 Subject: [PATCH 17/22] #49 feat: recuadro informativo de productos en el mapa de provenance sin desplazar posiciones --- AGENTS.md | 2 +- src/components/provenance/ProvenanceMap.vue | 141 ++++++++++-------- .../provenance/ProvenanceRelationModal.vue | 21 ++- .../provenance/ProvenanceMap.spec.js | 81 +++++++--- .../ProvenanceRelationModal.spec.js | 17 ++- 5 files changed, 180 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7bb2ca8..5f6ae54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,7 @@ No hay un estilo mayoritario. El código histórico está partido: - Payload: `[{ product: {id, name, catalogue_images[]}, suppliers: [{ supplier: {...}, organization|null, municipality|null, department|null, country|null }] }]`. - **Genérico reutilizable:** - `src/components/graph/VisChart.vue`: wrapper de vis-network (props `nodes`, `edges`, `height`, `options`; import `import { DataSet, Network } from 'vis-network/standalone'`; emite `select` con el nodo). **OJO**: nodo con `image: null` → TypeError de vis; omitir la clave `image` si no hay foto -- **Específico público** (`src/components/provenance/`): builder puro en `provenance-graph.js` (`buildProvenanceGraph(provenance, kinds)`, `hasAnySupplier`) y el adaptador vis `provenance-vis.js` (`toVisNodes`, `toVisEdges`, `chartOptions`). **Semántica de certeza**: arista con `certain: true` es continua (inequívoca) y `certain: false` es discontinua (dudosa); con varios proveedores por producto se inserta un nodo `junction:` (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:` (disyunción) con arista sólida hasta él y discontinua hacia cada proveedor; la duda se corta donde los proveedores coinciden (misma organización/municipio/departamento). Las aristas se deduplican por par `(from, to)` y si un mismo par repite con distinta certeza gana la duda. `buildProvenanceGraph` acepta los niveles a graficar (`product`, `supplier`, `organization`, `municipality`, `department`, `country`); los niveles omitidos se saltan conectando el nivel previo con el siguiente. `ProvenanceGraph.vue` unifica los charts en uno con checkboxes de filtro (por defecto solo productos y proveedores), leyenda con el color de cada nivel (`KIND_COLORS` en `provenance-vis.js`), columnas por nivel (x fijo por tipo; la física ordena la y) y espaciado vertical mínimo (`minVerticalSpacing` en `VisChart`); muestra "próximamente estará disponible" cuando no hay relaciones. `ProvenanceSection.vue` muestra el título ("Origen de los productos") con un desplegable (clic en el título o botón chevron) que oculta el gráfico por defecto, y un segundo desplegable para el mapa ("Mapa de origen de los productos") — patrón reutilizable para futuros bloques. `ProvenanceMap.vue` muestra un recuadro informativo (lista) con **todos** los productos del payload —incluidos los sin proveedor o cuyo municipio no tiene coordenadas, marcados "Sin geolocalización"— y, si hay al menos un municipio con coordenadas, el mapa leaflet debajo: un marcador por producto en el municipio de origen (usa `municipality.latitude/longitude` del payload, sin desplazar posiciones aunque coincidan) y un ícono de persona en la posición de la tienda (settings store, endpoint público `getStoreSettings`); `fitBounds` abarca todos los marcadores, al hacer hover sobre un producto dibuja una línea discontinua hasta la tienda y al hacer hover sobre la tienda dibuja las de todos los productos; clic en un marcador abre `ProvenanceRelationModal.vue` (producto + proveedor + organización + territorio). Clic en un producto del recuadro: si tiene ubicación hace `flyTo` al punto y abre el diálogo; si no, solo abre el diálogo (que para productos sin geolocalización muestra el proveedor/organización disponibles y la nota "Aún sin geolocalización registrada.", y para productos sin proveedor la nota "Aún no se ha vinculado un proveedor a este producto."). `ProvenanceDetailModal.vue` - **Admin CRUD** (`src/components/provenance/admin/`): `OrganizationsManagement.vue`, `SuppliersManagement.vue`, `GeographyManagement.vue` (tabs países/departamentos/municipios), `SupplierLinkDialog.vue` (vincula productos↔proveedores, abierto desde `ProductsManagement.vue`). Páginas en `src/pages/admin/{organizations,suppliers,geography}.vue`; rutas en `ADMIN_ROUTES` (`router/index.js`); ítems en `NavBar.vue` - **Endpoints provenance**: `/don_confiao/api/organizations/`, `/suppliers/`, `/countries/`, `/departments/`, `/municipalities/` (CRUD); vincular productos con `PATCH /don_confiao/api/products//` body `{"suppliers": [ids]}`; detalle de producto (con `suppliers`) via `GET /don_confiao/api/products//` - Los tests mockean `vis-network/standalone` (`vi.mock('vis-network/standalone', ...)`) o el propio `VisChart.vue`, y la API con `global.provide: { api }` diff --git a/src/components/provenance/ProvenanceMap.vue b/src/components/provenance/ProvenanceMap.vue index 829d947..a11a4a0 100644 --- a/src/components/provenance/ProvenanceMap.vue +++ b/src/components/provenance/ProvenanceMap.vue @@ -1,27 +1,52 @@