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() +}