#49 feat: CRUD admin de organizaciones, proveedores y geografía

This commit is contained in:
2026-08-15 17:40:34 -05:00
parent 4ecfb64118
commit 760b3031b2
12 changed files with 1739 additions and 0 deletions

View File

@@ -111,6 +111,9 @@
{ title: 'Imágenes de Catálogo', route: '/admin/catalogue-images', icon: 'mdi-image-multiple'}, { 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: '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: '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 }, { divider: true },
{ header: 'Sincronización Tryton' }, { header: 'Sincronización Tryton' },
{ title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'}, { title: 'Importar Productos', route: '/sincronizar_productos_tryton', icon: 'mdi-download'},

View File

@@ -0,0 +1,687 @@
<template>
<v-container fluid>
<h1 class="text-h4 mb-4">Gestión de Geografía</h1>
<v-tabs v-model="activeTab" color="primary">
<v-tab
data-testid="geo-tab-countries"
value="countries"
>
Países
</v-tab>
<v-tab
data-testid="geo-tab-departments"
value="departments"
>
Departamentos
</v-tab>
<v-tab
data-testid="geo-tab-municipalities"
value="municipalities"
>
Municipios
</v-tab>
</v-tabs>
<!-- Países -->
<v-row v-if="activeTab === 'countries'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-country-create"
prepend-icon="mdi-plus"
@click="openCreateCountry"
>
Nuevo país
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="countryHeaders"
item-value="id"
:items="countries"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-country-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditCountry(item)"
/>
<v-btn
color="error"
:data-testid="'geo-country-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteCountry(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay países para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Departamentos -->
<v-row v-if="activeTab === 'departments'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-department-create"
prepend-icon="mdi-plus"
@click="openCreateDepartment"
>
Nuevo departamento
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="departmentHeaders"
item-value="id"
:items="departments"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.country="{ item }">
{{ item.country_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-department-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditDepartment(item)"
/>
<v-btn
color="error"
:data-testid="'geo-department-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteDepartment(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay departamentos para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Municipios -->
<v-row v-if="activeTab === 'municipalities'" class="mt-2">
<v-col class="text-right" cols="12">
<v-btn
color="primary"
data-testid="geo-municipality-create"
prepend-icon="mdi-plus"
@click="openCreateMunicipality"
>
Nuevo municipio
</v-btn>
</v-col>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="municipalityHeaders"
item-value="id"
:items="municipalities"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.department="{ item }">
{{ item.department_detail?.name }}
</template>
<template #item.country="{ item }">
{{ item.country_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'geo-municipality-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEditMunicipality(item)"
/>
<v-btn
color="error"
:data-testid="'geo-municipality-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDeleteMunicipality(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay municipios para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<!-- Diálogo país -->
<v-dialog v-model="countryDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ countryDialog.isEdit ? 'Editar país' : 'Nuevo país' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveCountry">
<v-text-field
v-model="countryForm.name"
data-testid="geo-country-form-name"
label="Nombre"
required
/>
<v-text-field
v-model="countryForm.code"
data-testid="geo-country-form-code"
label="Código (ISO)"
maxlength="3"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="countryDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-country-save"
:disabled="saving"
variant="elevated"
@click="saveCountry"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Diálogo departamento -->
<v-dialog v-model="departmentDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ departmentDialog.isEdit ? 'Editar departamento' : 'Nuevo departamento' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveDepartment">
<v-text-field
v-model="departmentForm.name"
data-testid="geo-department-form-name"
label="Nombre"
required
/>
<v-select
v-model="departmentForm.country"
data-testid="geo-department-form-country"
item-title="name"
item-value="id"
:items="countries"
label="País"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="departmentDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-department-save"
:disabled="saving"
variant="elevated"
@click="saveDepartment"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Diálogo municipio -->
<v-dialog v-model="municipalityDialog.show" max-width="480">
<v-card>
<v-card-title>
{{ municipalityDialog.isEdit ? 'Editar municipio' : 'Nuevo municipio' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="saveMunicipality">
<v-text-field
v-model="municipalityForm.name"
data-testid="geo-municipality-form-name"
label="Nombre"
required
/>
<v-autocomplete
v-model="municipalityForm.department"
data-testid="geo-municipality-form-department"
item-title="name"
item-value="id"
:items="departmentItems"
label="Departamento"
required
:search-input="departmentSearch"
/>
<v-select
v-model="municipalityForm.country"
data-testid="geo-municipality-form-country"
item-title="name"
item-value="id"
:items="countries"
label="País"
required
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="municipalityDialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="geo-municipality-save"
:disabled="saving"
variant="elevated"
@click="saveMunicipality"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteCountryDialog" max-width="420">
<v-card>
<v-card-title>Eliminar país</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteCountryTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteCountryDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteCountry"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDepartmentDialog" max-width="420">
<v-card>
<v-card-title>Eliminar departamento</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDepartmentTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDepartmentDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteDepartment"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteMunicipalityDialog" max-width="420">
<v-card>
<v-card-title>Eliminar municipio</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteMunicipalityTarget?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteMunicipalityDialog = false">Cancelar</v-btn>
<v-btn
color="error"
:disabled="saving"
variant="elevated"
@click="confirmDeleteMunicipality"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref, watch } from 'vue'
const api = inject('api')
const activeTab = ref('countries')
const countries = ref([])
const departments = ref([])
const municipalities = ref([])
const loading = ref(false)
const saving = ref(false)
const departmentSearch = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const countryHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Código', key: 'code' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const departmentHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'País', key: 'country' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const municipalityHeaders = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Departamento', key: 'department' },
{ title: 'País', key: 'country' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const countryDialog = ref({ show: false, isEdit: false, item: null })
const countryForm = ref({ name: '', code: '' })
const departmentDialog = ref({ show: false, isEdit: false, item: null })
const departmentForm = ref({ name: '', country: null })
const municipalityDialog = ref({ show: false, isEdit: false, item: null })
const municipalityForm = ref({ name: '', department: null, country: null })
const departmentItems = computed(() => {
const query = departmentSearch.value.trim().toLowerCase()
let filtered = departments.value
if (query) {
filtered = departments.value.filter(department => {
return (department.name || '').toLowerCase().includes(query)
})
}
const selectedId = Number(municipalityForm.value.department)
const hasSelected = filtered.some(department => department.id === selectedId)
if (selectedId && !hasSelected) {
const selected = departments.value.find(department => department.id === selectedId)
if (selected) filtered = [selected, ...filtered]
}
return filtered
})
async function loadCountries () {
loading.value = true
try {
countries.value = await api.getCountries()
} catch (error) {
console.error('Error al cargar países:', error)
showSnackbar('Error al cargar países', 'error')
} finally {
loading.value = false
}
}
async function loadDepartments () {
loading.value = true
try {
departments.value = await api.getDepartments()
} catch (error) {
console.error('Error al cargar departamentos:', error)
showSnackbar('Error al cargar departamentos', 'error')
} finally {
loading.value = false
}
}
async function loadMunicipalities () {
loading.value = true
try {
municipalities.value = await api.getMunicipalities()
} catch (error) {
console.error('Error al cargar municipios:', error)
showSnackbar('Error al cargar municipios', 'error')
} finally {
loading.value = false
}
}
function openCreateCountry () {
countryForm.value = { name: '', code: '' }
countryDialog.value = { show: true, isEdit: false, item: null }
}
function openEditCountry (item) {
countryForm.value = { name: item.name, code: item.code }
countryDialog.value = { show: true, isEdit: true, item }
}
async function saveCountry () {
saving.value = true
try {
if (countryDialog.value.isEdit) {
await api.updateCountry(countryDialog.value.item.id, countryForm.value)
showSnackbar('País actualizado', 'success')
} else {
await api.createCountry(countryForm.value)
showSnackbar('País creado', 'success')
}
countryDialog.value.show = false
await loadCountries()
} catch (error) {
console.error('Error al guardar país:', error)
showSnackbar('Error al guardar país', 'error')
} finally {
saving.value = false
}
}
function openDeleteCountry (item) {
deleteCountryTarget.value = item
deleteCountryDialog.value = true
}
const deleteCountryDialog = ref(false)
const deleteCountryTarget = ref(null)
async function confirmDeleteCountry () {
saving.value = true
try {
await api.deleteCountry(deleteCountryTarget.value.id)
deleteCountryDialog.value = false
showSnackbar('País eliminado', 'success')
await loadCountries()
} catch (error) {
console.error('Error al eliminar país:', error)
showSnackbar('Error al eliminar país', 'error')
} finally {
saving.value = false
}
}
function openCreateDepartment () {
departmentForm.value = { name: '', country: null }
departmentDialog.value = { show: true, isEdit: false, item: null }
}
function openEditDepartment (item) {
departmentForm.value = { name: item.name, country: item.country }
departmentDialog.value = { show: true, isEdit: true, item }
}
async function saveDepartment () {
saving.value = true
try {
if (departmentDialog.value.isEdit) {
await api.updateDepartment(departmentDialog.value.item.id, departmentForm.value)
showSnackbar('Departamento actualizado', 'success')
} else {
await api.createDepartment(departmentForm.value)
showSnackbar('Departamento creado', 'success')
}
departmentDialog.value.show = false
await loadDepartments()
} catch (error) {
console.error('Error al guardar departamento:', error)
showSnackbar('Error al guardar departamento', 'error')
} finally {
saving.value = false
}
}
const deleteDepartmentDialog = ref(false)
const deleteDepartmentTarget = ref(null)
function openDeleteDepartment (item) {
deleteDepartmentTarget.value = item
deleteDepartmentDialog.value = true
}
async function confirmDeleteDepartment () {
saving.value = true
try {
await api.deleteDepartment(deleteDepartmentTarget.value.id)
deleteDepartmentDialog.value = false
showSnackbar('Departamento eliminado', 'success')
await loadDepartments()
} catch (error) {
console.error('Error al eliminar departamento:', error)
showSnackbar('Error al eliminar departamento', 'error')
} finally {
saving.value = false
}
}
function openCreateMunicipality () {
municipalityForm.value = { name: '', department: null, country: null }
municipalityDialog.value = { show: true, isEdit: false, item: null }
}
function openEditMunicipality (item) {
municipalityForm.value = {
name: item.name,
department: item.department,
country: item.country,
}
municipalityDialog.value = { show: true, isEdit: true, item }
}
watch(
() => municipalityForm.value.department,
departmentId => {
if (!departmentId) return
const department = departments.value.find(item => item.id === departmentId)
if (department && department.country) {
municipalityForm.value.country = department.country
}
}
)
async function saveMunicipality () {
saving.value = true
try {
if (municipalityDialog.value.isEdit) {
await api.updateMunicipality(municipalityDialog.value.item.id, municipalityForm.value)
showSnackbar('Municipio actualizado', 'success')
} else {
await api.createMunicipality(municipalityForm.value)
showSnackbar('Municipio creado', 'success')
}
municipalityDialog.value.show = false
await loadMunicipalities()
} catch (error) {
console.error('Error al guardar municipio:', error)
showSnackbar('Error al guardar municipio', 'error')
} finally {
saving.value = false
}
}
const deleteMunicipalityDialog = ref(false)
const deleteMunicipalityTarget = ref(null)
function openDeleteMunicipality (item) {
deleteMunicipalityTarget.value = item
deleteMunicipalityDialog.value = true
}
async function confirmDeleteMunicipality () {
saving.value = true
try {
await api.deleteMunicipality(deleteMunicipalityTarget.value.id)
deleteMunicipalityDialog.value = false
showSnackbar('Municipio eliminado', 'success')
await loadMunicipalities()
} catch (error) {
console.error('Error al eliminar municipio:', error)
showSnackbar('Error al eliminar municipio', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
watch(activeTab, tab => {
if (tab === 'departments') loadDepartments()
if (tab === 'municipalities') {
loadMunicipalities()
loadDepartments()
}
})
onMounted(loadCountries)
</script>

View File

@@ -0,0 +1,299 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Gestión de Organizaciones</h1>
</v-col>
<v-col class="text-md-right" cols="12" md="6">
<v-btn
color="primary"
data-testid="org-create"
prepend-icon="mdi-plus"
@click="openCreate"
>
Nueva organización
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="searchQuery"
clearable
data-testid="org-search"
density="compact"
hide-details
label="Buscar por nombre"
prepend-inner-icon="mdi-magnify"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="headers"
item-value="id"
:items="filteredOrganizations"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.website="{ item }">
<a
v-if="item.website"
:href="item.website"
rel="noopener"
target="_blank"
>
{{ item.website }}
</a>
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'org-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(item)"
/>
<v-btn
color="error"
:data-testid="'org-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDelete(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay organizaciones para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="520">
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar organización' : 'Nueva organización' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="save">
<v-text-field
v-model="formData.name"
data-testid="org-form-name"
label="Nombre"
required
/>
<v-textarea
v-model="formData.description"
data-testid="org-form-description"
label="Descripción"
rows="3"
/>
<v-text-field
v-model="formData.website"
data-testid="org-form-website"
label="Sitio web"
/>
<v-text-field
v-model="formData.contact_email"
data-testid="org-form-email"
label="Correo de contacto"
/>
<v-text-field
v-model="formData.contact_phone"
data-testid="org-form-phone"
label="Teléfono de contacto"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="org-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="420">
<v-card>
<v-card-title>Eliminar organización</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">Cancelar</v-btn>
<v-btn
color="error"
data-testid="org-confirm-delete"
:disabled="saving"
variant="elevated"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
const api = inject('api')
const organizations = ref([])
const loading = ref(false)
const saving = ref(false)
const searchQuery = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const dialog = ref({ show: false, isEdit: false, item: null })
const deleteDialog = ref({ show: false, item: null })
const headers = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Descripción', key: 'description' },
{ title: 'Sitio web', key: 'website' },
{ title: 'Correo', key: 'contact_email' },
{ title: 'Teléfono', key: 'contact_phone' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const emptyForm = () => ({
name: '',
description: '',
website: '',
contact_email: '',
contact_phone: '',
})
const formData = ref(emptyForm())
const filteredOrganizations = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return organizations.value
return organizations.value.filter(organization => {
return (organization.name || '').toLowerCase().includes(query)
})
})
async function load () {
loading.value = true
try {
organizations.value = await api.getOrganizations()
} catch (error) {
console.error('Error al cargar organizaciones:', error)
showSnackbar('Error al cargar organizaciones', 'error')
} finally {
loading.value = false
}
}
function openCreate () {
formData.value = emptyForm()
dialog.value = { show: true, isEdit: false, item: null }
}
function openEdit (item) {
formData.value = {
name: item.name,
description: item.description,
website: item.website,
contact_email: item.contact_email,
contact_phone: item.contact_phone,
}
dialog.value = { show: true, isEdit: true, item }
}
async function save () {
saving.value = true
try {
if (dialog.value.isEdit) {
await api.updateOrganization(dialog.value.item.id, formData.value)
showSnackbar('Organización actualizada', 'success')
} else {
await api.createOrganization(formData.value)
showSnackbar('Organización creada', 'success')
}
dialog.value.show = false
await load()
} catch (error) {
console.error('Error al guardar organización:', error)
showSnackbar('Error al guardar organización', 'error')
} finally {
saving.value = false
}
}
function openDelete (item) {
deleteDialog.value = { show: true, item }
}
async function confirmDelete () {
saving.value = true
try {
await api.deleteOrganization(deleteDialog.value.item.id)
deleteDialog.value.show = false
showSnackbar('Organización eliminada', 'success')
await load()
} catch (error) {
console.error('Error al eliminar organización:', error)
showSnackbar('Error al eliminar organización', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
onMounted(load)
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,338 @@
<template>
<v-container fluid>
<v-row align="center">
<v-col cols="12" md="6">
<h1 class="text-h4">Gestión de Proveedores</h1>
</v-col>
<v-col class="text-md-right" cols="12" md="6">
<v-btn
color="primary"
data-testid="supplier-create"
prepend-icon="mdi-plus"
@click="openCreate"
>
Nuevo proveedor
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="searchQuery"
clearable
data-testid="supplier-search"
density="compact"
hide-details
label="Buscar por nombre"
prepend-inner-icon="mdi-magnify"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-card>
<v-data-table
density="compact"
:headers="headers"
item-value="id"
:items="filteredSuppliers"
items-per-page="25"
:items-per-page-options="[10, 25, 50, 100]"
:loading="loading"
>
<template #item.organization="{ item }">
{{ item.organization_detail?.name }}
</template>
<template #item.municipality="{ item }">
{{ item.municipality_detail?.name }}
</template>
<template #item.actions="{ item }">
<v-btn
:data-testid="'supplier-edit-' + item.id"
icon="mdi-pencil"
size="small"
variant="text"
@click="openEdit(item)"
/>
<v-btn
color="error"
:data-testid="'supplier-delete-' + item.id"
icon="mdi-delete"
size="small"
variant="text"
@click="openDelete(item)"
/>
</template>
<template #loading>
<v-skeleton-loader type="table-row@10" />
</template>
<template #no-data>
<v-alert class="my-4" type="info" variant="tonal">
No hay proveedores para mostrar
</v-alert>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="dialog.show" max-width="560">
<v-card>
<v-card-title>
{{ dialog.isEdit ? 'Editar proveedor' : 'Nuevo proveedor' }}
</v-card-title>
<v-card-text>
<v-form @submit.prevent="save">
<v-text-field
v-model="formData.name"
data-testid="supplier-form-name"
label="Nombre"
required
/>
<v-textarea
v-model="formData.description"
data-testid="supplier-form-description"
label="Descripción"
rows="3"
/>
<v-select
v-model="formData.organization"
clearable
data-testid="supplier-form-organization"
item-title="name"
item-value="id"
:items="organizations"
label="Organización"
/>
<v-autocomplete
v-model="formData.municipality"
clearable
data-testid="supplier-form-municipality"
item-title="name"
item-value="id"
:items="municipalityItems"
label="Municipio"
:search-input="municipalitySearch"
/>
<v-text-field
v-model="formData.contact_email"
data-testid="supplier-form-email"
label="Correo de contacto"
/>
<v-text-field
v-model="formData.contact_phone"
data-testid="supplier-form-phone"
label="Teléfono de contacto"
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog.show = false">Cancelar</v-btn>
<v-btn
color="primary"
data-testid="supplier-save"
:disabled="saving"
variant="elevated"
@click="save"
>
Guardar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog.show" max-width="420">
<v-card>
<v-card-title>Eliminar proveedor</v-card-title>
<v-card-text>
¿Está seguro de eliminar "{{ deleteDialog.item?.name }}"?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog.show = false">Cancelar</v-btn>
<v-btn
color="error"
data-testid="supplier-confirm-delete"
:disabled="saving"
variant="elevated"
@click="confirmDelete"
>
Eliminar
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
location="top"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn variant="text" @click="snackbar.show = false">Cerrar</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
const api = inject('api')
const suppliers = ref([])
const organizations = ref([])
const municipalities = ref([])
const loading = ref(false)
const saving = ref(false)
const searchQuery = ref('')
const municipalitySearch = ref('')
const snackbar = ref({ show: false, message: '', color: 'success' })
const dialog = ref({ show: false, isEdit: false, item: null })
const deleteDialog = ref({ show: false, item: null })
const headers = [
{ title: 'ID', key: 'id' },
{ title: 'Nombre', key: 'name' },
{ title: 'Organización', key: 'organization' },
{ title: 'Municipio', key: 'municipality' },
{ title: 'Correo', key: 'contact_email' },
{ title: 'Teléfono', key: 'contact_phone' },
{ title: 'Acciones', key: 'actions', sortable: false },
]
const emptyForm = () => ({
name: '',
description: '',
organization: null,
municipality: null,
contact_email: '',
contact_phone: '',
})
const formData = ref(emptyForm())
const filteredSuppliers = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
if (!query) return suppliers.value
return suppliers.value.filter(supplier => {
return (supplier.name || '').toLowerCase().includes(query)
})
})
const municipalityItems = computed(() => {
const query = municipalitySearch.value.trim().toLowerCase()
let filtered = municipalities.value
if (query) {
filtered = municipalities.value.filter(municipality => {
return (municipality.name || '').toLowerCase().includes(query)
})
}
const selectedId = Number(formData.value.municipality)
const hasSelected = filtered.some(municipality => municipality.id === selectedId)
if (selectedId && !hasSelected) {
const selected = municipalities.value.find(municipality => municipality.id === selectedId)
if (selected) filtered = [selected, ...filtered]
}
return filtered
})
async function load () {
loading.value = true
try {
const [suppliersData, organizationsData, municipalitiesData] = await Promise.all([
api.getSuppliers(),
api.getOrganizations(),
api.getMunicipalities(),
])
suppliers.value = suppliersData
organizations.value = organizationsData
municipalities.value = municipalitiesData
} catch (error) {
console.error('Error al cargar proveedores:', error)
showSnackbar('Error al cargar proveedores', 'error')
} finally {
loading.value = false
}
}
function openCreate () {
formData.value = emptyForm()
dialog.value = { show: true, isEdit: false, item: null }
}
function openEdit (item) {
formData.value = {
name: item.name,
description: item.description,
organization: item.organization,
municipality: item.municipality,
contact_email: item.contact_email,
contact_phone: item.contact_phone,
}
dialog.value = { show: true, isEdit: true, item }
}
async function save () {
saving.value = true
try {
if (dialog.value.isEdit) {
await api.updateSupplier(dialog.value.item.id, formData.value)
showSnackbar('Proveedor actualizado', 'success')
} else {
await api.createSupplier(formData.value)
showSnackbar('Proveedor creado', 'success')
}
dialog.value.show = false
await load()
} catch (error) {
console.error('Error al guardar proveedor:', error)
showSnackbar('Error al guardar proveedor', 'error')
} finally {
saving.value = false
}
}
function openDelete (item) {
deleteDialog.value = { show: true, item }
}
async function confirmDelete () {
saving.value = true
try {
await api.deleteSupplier(deleteDialog.value.item.id)
deleteDialog.value.show = false
showSnackbar('Proveedor eliminado', 'success')
await load()
} catch (error) {
console.error('Error al eliminar proveedor:', error)
showSnackbar('Error al eliminar proveedor', 'error')
} finally {
saving.value = false
}
}
function showSnackbar (message, color) {
snackbar.value = { show: true, message, color }
}
onMounted(load)
</script>
<style scoped>
.text-md-right {
text-align: right;
}
@media (max-width: 960px) {
.text-md-right {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<GeographyManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import GeographyManagement from '@/components/provenance/admin/GeographyManagement.vue'
const authStore = useAuthStore()
</script>

View File

@@ -0,0 +1,10 @@
<template>
<OrganizationsManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import OrganizationsManagement from '@/components/provenance/admin/OrganizationsManagement.vue'
const authStore = useAuthStore()
</script>

View File

@@ -0,0 +1,10 @@
<template>
<SuppliersManagement v-if="authStore.isAdmin" />
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import SuppliersManagement from '@/components/provenance/admin/SuppliersManagement.vue'
const authStore = useAuthStore()
</script>

View File

@@ -25,6 +25,9 @@ const ADMIN_ROUTES = [
'/admin/catalog-sales', '/admin/catalog-sales',
'/admin/catalogue-images', '/admin/catalogue-images',
'/admin/store-settings', '/admin/store-settings',
'/admin/organizations',
'/admin/suppliers',
'/admin/geography',
] ]
const router = createRouter({ const router = createRouter({

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

@@ -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)
})
})

View File

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