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 @@
+
+
+ Gestión de Geografía
+
+
+
+ Países
+
+
+ Departamentos
+
+
+ Municipios
+
+
+
+
+
+
+
+ Nuevo país
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No hay países para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ Nuevo departamento
+
+
+
+
+
+
+ {{ item.country_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay departamentos para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ Nuevo municipio
+
+
+
+
+
+
+ {{ item.department_detail?.name }}
+
+
+ {{ item.country_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay municipios para mostrar
+
+
+
+
+
+
+
+
+
+
+
+ {{ countryDialog.isEdit ? 'Editar país' : 'Nuevo país' }}
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+
+
+ {{ departmentDialog.isEdit ? 'Editar departamento' : 'Nuevo departamento' }}
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+
+
+ {{ municipalityDialog.isEdit ? 'Editar municipio' : 'Nuevo municipio' }}
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar país
+
+ ¿Está seguro de eliminar "{{ deleteCountryTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+
+ Eliminar departamento
+
+ ¿Está seguro de eliminar "{{ deleteDepartmentTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+
+ Eliminar municipio
+
+ ¿Está seguro de eliminar "{{ deleteMunicipalityTarget?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
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 @@
+
+
+
+
+ Gestión de Organizaciones
+
+
+
+ Nueva organización
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.website }}
+
+
+
+
+
+
+
+
+
+
+
+ No hay organizaciones para mostrar
+
+
+
+
+
+
+
+
+
+
+ {{ dialog.isEdit ? 'Editar organización' : 'Nueva organización' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar organización
+
+ ¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ Gestión de Proveedores
+
+
+
+ Nuevo proveedor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.organization_detail?.name }}
+
+
+ {{ item.municipality_detail?.name }}
+
+
+
+
+
+
+
+
+
+
+ No hay proveedores para mostrar
+
+
+
+
+
+
+
+
+
+
+ {{ dialog.isEdit ? 'Editar proveedor' : 'Nuevo proveedor' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancelar
+
+ Guardar
+
+
+
+
+
+
+
+ Eliminar proveedor
+
+ ¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
+
+
+
+ Cancelar
+
+ Eliminar
+
+
+
+
+
+
+ {{ snackbar.message }}
+
+ Cerrar
+
+
+
+
+
+
+
+
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()
+}